From d7bce7fb56d80be8ef2e0e52a8446a6f819d5921 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 11 Aug 2025 19:49:52 +0200 Subject: [PATCH] Facturas de cliente y clientes --- apps/server/package.json | 3 +- apps/server/src/lib/modules/module-loader.ts | 1 + apps/server/src/register-modules.ts | 8 +- docs/CONVENCION NOMBRADO DE ESQUEMAS DTO.md | 187 ++++++++ modules/core/package.json | 1 - modules/core/src/common/dto/critera.dto.ts | 27 ++ modules/core/src/common/dto/index.ts | 1 + modules/core/src/common/dto/list.view.dto.ts | 4 +- modules/customer-invoices/package.json | 12 +- .../express/customer-invoices.routes.ts | 3 +- .../mappers/contact.mapper.ts.bak | 63 --- .../mappers/contactAddress.mapper.ts.bak | 65 --- .../list-customer-invoices.query.dto.ts | 31 +- .../list-customer-invoices.result.dto.ts | 4 +- modules/customers/package.json | 24 +- .../create-customer.use-case.ts | 57 +++ .../api/application/create-customer/index.ts | 2 + .../presenter/create-customers.presenter.ts | 27 ++ .../create-customer/presenter/index.ts | 1 + .../delete-customer.use-case.ts | 40 ++ .../api/application/delete-customer/index.ts | 1 + .../get-customer/get-customer.use-case.ts | 36 ++ .../src/api/application/get-customer/index.ts | 2 + .../presenter/InvoiceItem.presenter.ts.bak | 16 + .../InvoiceParticipant.presenter.ts.bak | 26 ++ ...InvoiceParticipantAddress.presenter.ts.bak | 19 + .../presenter/get-invoice.presenter.ts | 65 +++ .../get-customer/presenter/index.ts | 1 + .../helpers/extract-or-push-error.ts | 45 ++ .../helpers/has-no-undefined-fields.ts | 50 +++ .../src/api/application/helpers/index.ts | 1 + .../map-dto-to-customer-items-props.ts | 83 ++++ .../helpers/map-dto-to-customer-props.ts | 78 ++++ .../customers/src/api/application/index.ts | 5 + .../api/application/list-customers/index.ts | 1 + .../list-customers/list-customers.use-case.ts | 33 ++ .../list-customers/presenter/index.ts | 1 + .../presenter/list-invoices.presenter.ts | 54 +++ .../src/api/application/services/index.ts | 2 + .../services/participantAddressFinder.ts | 64 +++ .../application/services/participantFinder.ts | 21 + .../api/application/update-customer/index.ts | 1 + .../update-customer.use-case.ts | 401 ++++++++++++++++++ .../create-customer/create-customer.ts | 34 ++ .../api/controllers/create-customer/index.ts | 17 + .../delete-invoice.controller.ts | 30 ++ .../api/controllers/delete-customer/index.ts | 16 + .../get-customer/get-invoice.controller.ts | 30 ++ .../src/api/controllers/get-customer/index.ts | 17 + .../customers/src/api/controllers/index.ts | 5 + .../api/controllers/list-customers/index.ts | 18 + .../list-customers.controller.ts | 33 ++ .../controllers/update-customer/index.ts.bak | 57 +++ .../presenter/InvoiceItem.presenter.ts.bak | 19 + .../InvoiceParticipant.presenter.ts.bak | 26 ++ ...InvoiceParticipantAddress.presenter.ts.bak | 19 + .../presenter/UpdateInvoice.presenter.ts.bak | 33 ++ .../update-customer/presenter/index.ts.bak | 1 + .../update-invoice.controller.ts.bak | 72 ++++ .../src/api/domain/aggregates/customer.ts | 130 ++++++ .../src/api/domain/aggregates/index.ts | 1 + modules/customers/src/api/domain/index.ts | 4 + .../customer-repository.interface.ts | 50 +++ .../src/api/domain/repositories/index.ts | 1 + .../services/customer-service.interface.ts | 33 ++ .../api/domain/services/customer.service.ts | 149 +++++++ .../src/api/domain/services/index.ts | 2 + .../value-objects/customer-address-type.ts | 38 ++ .../domain/value-objects/customer-number.ts | 48 +++ .../domain/value-objects/customer-serie.ts | 56 +++ .../domain/value-objects/customer-status.ts | 94 ++++ .../src/api/domain/value-objects/index.ts | 4 + modules/customers/src/api/index.ts | 7 +- .../express/customers.routes.ts | 78 ++++ .../src/api/infrastructure/express/index.ts | 1 + .../customers/src/api/infrastructure/index.ts | 3 + .../mappers/customer-item.mapper.ts | 128 ++++++ .../infrastructure/mappers/customer.mapper.ts | 97 +++++ .../src/api/infrastructure/mappers/index.ts | 1 + .../sequelize/customer.model.ts | 175 ++++++++ .../sequelize/customer.repository.ts | 121 ++++++ .../src/api/infrastructure/sequelize/index.ts | 7 + .../request/create-customer.command.dto.ts | 36 ++ .../dto/request/customer-list.criteria.dto.ts | 33 ++ .../delete-customer-by-id.params.dto.ts | 13 + .../request/get-customer-by-id.params.dto.ts | 13 + .../dto/request/list-customers.query.dto.ts | 39 -- .../request/update-customer.command.dto.ts | 0 .../response/customer-created.response.dto.ts | 17 + ...t.dto.ts => customer-list.response.dto.ts} | 18 +- .../response/get-customer-by-id.result.dto.ts | 17 + .../src/common/dto/response/index.ts | 2 +- .../src/web/components/client-selector.tsx | 15 +- .../web/components/customer-prices-card.tsx | 88 ++++ .../web/components/customer-status-badge.tsx | 65 +++ .../customer-taxes-multi-select.tsx | 73 ++++ .../src/web/components/customers-layout.tsx | 6 + .../web/components/customers-list-grid.tsx | 83 ++++ .../src/web/context/customers-context.tsx | 55 +++ modules/customers/src/web/customer-routes.tsx | 61 +++ .../web/hooks/use-create-customer-mutation.ts | 19 + .../src/web/hooks/use-customers-context.tsx | 11 + .../src/web/hooks/use-customers-query.tsx | 15 +- .../customers/src/web/hooks/use-customers.bak | 75 ++++ modules/customers/src/web/manifest.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 203 ++------- 107 files changed, 3769 insertions(+), 414 deletions(-) create mode 100644 docs/CONVENCION NOMBRADO DE ESQUEMAS DTO.md create mode 100644 modules/core/src/common/dto/critera.dto.ts delete mode 100644 modules/customer-invoices/src/api/infrastructure/mappers/contact.mapper.ts.bak delete mode 100644 modules/customer-invoices/src/api/infrastructure/mappers/contactAddress.mapper.ts.bak create mode 100644 modules/customers/src/api/application/create-customer/create-customer.use-case.ts create mode 100644 modules/customers/src/api/application/create-customer/index.ts create mode 100644 modules/customers/src/api/application/create-customer/presenter/create-customers.presenter.ts create mode 100644 modules/customers/src/api/application/create-customer/presenter/index.ts create mode 100644 modules/customers/src/api/application/delete-customer/delete-customer.use-case.ts create mode 100644 modules/customers/src/api/application/delete-customer/index.ts create mode 100644 modules/customers/src/api/application/get-customer/get-customer.use-case.ts create mode 100644 modules/customers/src/api/application/get-customer/index.ts create mode 100644 modules/customers/src/api/application/get-customer/presenter/InvoiceItem.presenter.ts.bak create mode 100644 modules/customers/src/api/application/get-customer/presenter/InvoiceParticipant.presenter.ts.bak create mode 100644 modules/customers/src/api/application/get-customer/presenter/InvoiceParticipantAddress.presenter.ts.bak create mode 100644 modules/customers/src/api/application/get-customer/presenter/get-invoice.presenter.ts create mode 100644 modules/customers/src/api/application/get-customer/presenter/index.ts create mode 100644 modules/customers/src/api/application/helpers/extract-or-push-error.ts create mode 100644 modules/customers/src/api/application/helpers/has-no-undefined-fields.ts create mode 100644 modules/customers/src/api/application/helpers/index.ts create mode 100644 modules/customers/src/api/application/helpers/map-dto-to-customer-items-props.ts create mode 100644 modules/customers/src/api/application/helpers/map-dto-to-customer-props.ts create mode 100644 modules/customers/src/api/application/index.ts create mode 100644 modules/customers/src/api/application/list-customers/index.ts create mode 100644 modules/customers/src/api/application/list-customers/list-customers.use-case.ts create mode 100644 modules/customers/src/api/application/list-customers/presenter/index.ts create mode 100644 modules/customers/src/api/application/list-customers/presenter/list-invoices.presenter.ts create mode 100644 modules/customers/src/api/application/services/index.ts create mode 100644 modules/customers/src/api/application/services/participantAddressFinder.ts create mode 100644 modules/customers/src/api/application/services/participantFinder.ts create mode 100644 modules/customers/src/api/application/update-customer/index.ts create mode 100644 modules/customers/src/api/application/update-customer/update-customer.use-case.ts create mode 100644 modules/customers/src/api/controllers/create-customer/create-customer.ts create mode 100644 modules/customers/src/api/controllers/create-customer/index.ts create mode 100644 modules/customers/src/api/controllers/delete-customer/delete-invoice.controller.ts create mode 100644 modules/customers/src/api/controllers/delete-customer/index.ts create mode 100644 modules/customers/src/api/controllers/get-customer/get-invoice.controller.ts create mode 100644 modules/customers/src/api/controllers/get-customer/index.ts create mode 100644 modules/customers/src/api/controllers/index.ts create mode 100644 modules/customers/src/api/controllers/list-customers/index.ts create mode 100644 modules/customers/src/api/controllers/list-customers/list-customers.controller.ts create mode 100644 modules/customers/src/api/controllers/update-customer/index.ts.bak create mode 100644 modules/customers/src/api/controllers/update-customer/presenter/InvoiceItem.presenter.ts.bak create mode 100644 modules/customers/src/api/controllers/update-customer/presenter/InvoiceParticipant.presenter.ts.bak create mode 100644 modules/customers/src/api/controllers/update-customer/presenter/InvoiceParticipantAddress.presenter.ts.bak create mode 100644 modules/customers/src/api/controllers/update-customer/presenter/UpdateInvoice.presenter.ts.bak create mode 100644 modules/customers/src/api/controllers/update-customer/presenter/index.ts.bak create mode 100644 modules/customers/src/api/controllers/update-customer/update-invoice.controller.ts.bak create mode 100644 modules/customers/src/api/domain/aggregates/customer.ts create mode 100644 modules/customers/src/api/domain/aggregates/index.ts create mode 100644 modules/customers/src/api/domain/index.ts create mode 100644 modules/customers/src/api/domain/repositories/customer-repository.interface.ts create mode 100644 modules/customers/src/api/domain/repositories/index.ts create mode 100644 modules/customers/src/api/domain/services/customer-service.interface.ts create mode 100644 modules/customers/src/api/domain/services/customer.service.ts create mode 100644 modules/customers/src/api/domain/services/index.ts create mode 100644 modules/customers/src/api/domain/value-objects/customer-address-type.ts create mode 100644 modules/customers/src/api/domain/value-objects/customer-number.ts create mode 100644 modules/customers/src/api/domain/value-objects/customer-serie.ts create mode 100644 modules/customers/src/api/domain/value-objects/customer-status.ts create mode 100644 modules/customers/src/api/domain/value-objects/index.ts create mode 100644 modules/customers/src/api/infrastructure/express/customers.routes.ts create mode 100644 modules/customers/src/api/infrastructure/express/index.ts create mode 100644 modules/customers/src/api/infrastructure/index.ts create mode 100644 modules/customers/src/api/infrastructure/mappers/customer-item.mapper.ts create mode 100644 modules/customers/src/api/infrastructure/mappers/customer.mapper.ts create mode 100644 modules/customers/src/api/infrastructure/mappers/index.ts create mode 100644 modules/customers/src/api/infrastructure/sequelize/customer.model.ts create mode 100644 modules/customers/src/api/infrastructure/sequelize/customer.repository.ts create mode 100644 modules/customers/src/api/infrastructure/sequelize/index.ts create mode 100644 modules/customers/src/common/dto/request/create-customer.command.dto.ts create mode 100644 modules/customers/src/common/dto/request/customer-list.criteria.dto.ts create mode 100644 modules/customers/src/common/dto/request/delete-customer-by-id.params.dto.ts create mode 100644 modules/customers/src/common/dto/request/get-customer-by-id.params.dto.ts delete mode 100644 modules/customers/src/common/dto/request/list-customers.query.dto.ts create mode 100644 modules/customers/src/common/dto/request/update-customer.command.dto.ts create mode 100644 modules/customers/src/common/dto/response/customer-created.response.dto.ts rename modules/customers/src/common/dto/response/{list-customers.result.dto.ts => customer-list.response.dto.ts} (51%) create mode 100644 modules/customers/src/common/dto/response/get-customer-by-id.result.dto.ts create mode 100644 modules/customers/src/web/components/customer-prices-card.tsx create mode 100644 modules/customers/src/web/components/customer-status-badge.tsx create mode 100644 modules/customers/src/web/components/customer-taxes-multi-select.tsx create mode 100644 modules/customers/src/web/components/customers-layout.tsx create mode 100644 modules/customers/src/web/components/customers-list-grid.tsx create mode 100644 modules/customers/src/web/context/customers-context.tsx create mode 100644 modules/customers/src/web/customer-routes.tsx create mode 100644 modules/customers/src/web/hooks/use-create-customer-mutation.ts create mode 100644 modules/customers/src/web/hooks/use-customers-context.tsx create mode 100644 modules/customers/src/web/hooks/use-customers.bak diff --git a/apps/server/package.json b/apps/server/package.json index 58b93de7..59848912 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -43,6 +43,7 @@ "dependencies": { "@erp/auth": "workspace:*", "@erp/core": "workspace:*", + "@erp/customers": "workspace:*", "@erp/customer-invoices": "workspace:*", "bcrypt": "^5.1.1", "cls-rtracer": "^2.6.3", @@ -69,7 +70,7 @@ "uuid": "^11.0.5", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", - "zod": "^3.24.1" + "zod": "^3.25.67" }, "engines": { "node": ">=22" diff --git a/apps/server/src/lib/modules/module-loader.ts b/apps/server/src/lib/modules/module-loader.ts index 7cf4c36a..ed1ed73c 100644 --- a/apps/server/src/lib/modules/module-loader.ts +++ b/apps/server/src/lib/modules/module-loader.ts @@ -7,6 +7,7 @@ const registeredModules: Map = new Map(); const initializedModules = new Set(); export function registerModule(pkg: IModuleServer) { + console.log(pkg.name); if (registeredModules.has(pkg.name)) { throw new Error(`❌ Paquete "${pkg.name}" ya registrado.`); } diff --git a/apps/server/src/register-modules.ts b/apps/server/src/register-modules.ts index 9667f162..7c18e5cb 100644 --- a/apps/server/src/register-modules.ts +++ b/apps/server/src/register-modules.ts @@ -1,8 +1,10 @@ -import { authAPIModule } from "@erp/auth/api"; -import { customerInvoicesAPIModule } from "@erp/customer-invoices/api"; +import customerInvoicesAPIModule from "@erp/customer-invoices/api"; +import customersAPIModule from "@erp/customers/api"; + import { registerModule } from "./lib"; export const registerModules = () => { - registerModule(authAPIModule); + //registerModule(authAPIModule); + registerModule(customersAPIModule); registerModule(customerInvoicesAPIModule); }; diff --git a/docs/CONVENCION NOMBRADO DE ESQUEMAS DTO.md b/docs/CONVENCION NOMBRADO DE ESQUEMAS DTO.md new file mode 100644 index 00000000..4fbdd200 --- /dev/null +++ b/docs/CONVENCION NOMBRADO DE ESQUEMAS DTO.md @@ -0,0 +1,187 @@ +# 📝 Guía de Convenciones para Nombres de Schemas + +Esta guía define cómo nombrar los Schemas de **Requests** y **Responses** según el tipo de operación y el contexto (recurso único o colección). + +--- + +## 1️⃣ Operaciones sobre recurso único + +**Patrón** + + + +- **Verbo**: describe la acción (`Get`, `Delete`, `Update`, `Patch`, `Create`). +- **Entidad**: en singular (`Product`, `Customer`, `Invoice`). +- **Condición**: criterio de selección (`ById`, `BySlug`, `ByEmail`). +- **TipoDeSchema**: origen o propósito (`ParamsSchema`, `CommandSchema`, `QuerySchema`). + +**Ejemplos ✅** +- `GetProductByIdParamsSchema` +- `DeleteCustomerByIdParamsSchema` +- `UpdateInvoiceByIdCommandSchema` + +**Ejemplos ❌** +- `ProductGetByIdParamsSchema` (orden invertido, menos natural). +- `GetProductsByIdParamsSchema` (plural innecesario en recurso único). + +**Lectura natural**: *"Esquema de parámetros para obtener un producto por id"*. + +--- + +## 2️⃣ Operaciones sobre colecciones + +**Patrón** +List + +- **Entidad**: en singular (`Product`, `Customer`). +- **List**: indica conjunto/listado. +- **Criterio/Filtro**: tipo de filtrado (`Criteria`, `Query`). +- **TipoDeSchema**: `Schema` o `ResponseSchema` según corresponda. + +**Ejemplos ✅** +- `ProductListCriteriaSchema` +- `CustomerListResponseSchema` +- `InvoiceListQuerySchema` + +**Ejemplos ❌** +- `ListProductCriteriaSchema` (el orden “ListProduct” suena a verbo y no a colección). +- `ProductsListCriteriaSchema` (plural innecesario en el nombre base). + +**Lectura natural**: *"Esquema de criterios para una lista de productos"*. + +--- + +## 3️⃣ Reglas generales + +1. **Singular siempre** para el nombre base de la entidad. +2. El **verbo solo** en operaciones sobre un recurso único o mutaciones. +3. En listados, el **sustantivo principal primero** (`ProductList`, `CustomerList`), nunca `ListProduct`. +4. Sufijos estándar: + - `CommandSchema` → body de mutaciones (create/update/patch). + - `ParamsSchema` → route params (`/:id`). + - `QuerySchema` → query string no normalizada. + - `CriteriaSchema` → query normalizada con patrón Criteria. + - `ResponseSchema` → respuesta de la API. +5. Mantener **consistencia**: si usas `ProductListCriteriaSchema`, no alternes con `ProductsCriteriaSchema`. + +--- + +## 4️⃣ Ejemplos positivos y negativos + +| ✅ Correcto | ❌ Incorrecto | Motivo del error | +|------------------------------------------|---------------------------------------------|----------------------------------------------| +| `GetCustomerByIdParamsSchema` | `CustomerGetByIdParamsSchema` | Orden invertido. | +| `ProductListCriteriaSchema` | `ListProductCriteriaSchema` | Orden “ListProduct” no natural en inglés. | +| `CustomerListResponseSchema` | `CustomersListResponseSchema` | Plural innecesario. | +| `DeleteInvoiceByIdParamsSchema` | `DeleteInvoicesByIdParamsSchema` | Plural innecesario en recurso único. | +| `UpdateProductByIdCommandSchema` | `UpdateProductCommandByIdSchema` | `ById` debe ir antes del tipo de esquema. | + +--- + + +# Convenciones de nombres para Schemas (Requests y Responses) + +## Tabla de rutas ↔ Schemas + +| **Método** | **Ruta** | **Caso de uso** | **Request Schema (validación)** | **Response Schema (DTO)** | **Notas** | +|-----------:|---------------------|--------------------------------------------------|--------------------------------------------|-------------------------------------------|-----------| +| GET | `/entities` | Listado con criterios (Criteria normalizado) | `EntityListCriteriaSchema` | `EntityListResponseSchema` | `data: EntitySummary[]`, `pageNumber`, `pageSize`, `totalItems`. | +| GET | `/entities` | Búsqueda/filtrado por query params “raw” | `SearchEntityQuerySchema` | `EntityListResponseSchema` | Úsalo si **no** aplicas patrón Criteria. | +| GET | `/entities/:id` | Obtener por ID | `GetEntityByIdParamsSchema` | `EntityDetailResponseSchema` | Si devuelves el recurso “puro”, puedes usar `EntityResourceSchema`. | +| POST | `/entities` | Crear recurso | `CreateEntityCommandSchema` | `EntityCreatedResponseSchema` **o** `EntityResourceSchema` | Si devuelves 201 + recurso completo, usa `EntityResourceSchema`. | +| PUT | `/entities/:id` | Reemplazar recurso (idempotente) | `UpdateEntityCommandSchema` + `UpdateEntityByIdParamsSchema` | `EntityResourceSchema` | También válido con PATCH para cambios parciales. | +| PATCH | `/entities/:id` | Actualizar parcialmente | `PatchEntityCommandSchema` + `UpdateEntityByIdParamsSchema` | `EntityResourceSchema` | Usa un comando separado si el payload difiere del PUT. | +| DELETE | `/entities/:id` | Eliminar por ID | `DeleteEntityByIdParamsSchema` | — (204 No Content) **o** `EntityDeletedResponseSchema` | 204 recomendado. Si devuelves body, usa `EntityDeletedResponseSchema`. | + +--- + +## Esquemas reutilizables (Responses) + +- **Item para listados:** + `EntitySummarySchema` + +- **Recurso completo:** + `EntityResourceSchema` + +- **Respuestas compuestas:** + - `EntityListResponseSchema` → `data: EntitySummarySchema[]` + paginación/meta + - `EntityDetailResponseSchema` → típicamente `EntityResourceSchema` + - `EntityCreatedResponseSchema` → `{ id, createdAt, ... }` si no devuelves el recurso completo + - `EntityDeletedResponseSchema` → `{ id, deleted: true }` si no usas 204 + +--- + +## Convenciones de sufijos + +### Requests +- `...CommandSchema` → **body** (mutaciones) +- `...ParamsSchema` → **route params** (`/:id`) +- `...QuerySchema` → **query string** (`?page=...`) +- `...CriteriaSchema` → para queries normalizadas con patrón Criteria + +### Responses +- `...ListResponseSchema` → listado paginado +- `...SummarySchema` → item de listado +- `...DetailResponseSchema` → detalle de recurso +- `...ResourceSchema` → representación completa del recurso +- `...CreatedResponseSchema` → recurso recién creado +- `...DeletedResponseSchema` → confirmación de borrado (si no usas 204) + +# Ejemplos de nombres de Schemas por entidad + +## 1. Customers + +### Requests +- `CreateCustomerCommandSchema` +- `UpdateCustomerCommandSchema` +- `PatchCustomerCommandSchema` +- `DeleteCustomerByIdParamsSchema` +- `GetCustomerByIdParamsSchema` +- `CustomerListCriteriaSchema` +- `SearchCustomerQuerySchema` + +### Responses +- `CustomerSummarySchema` +- `CustomerResourceSchema` +- `CustomerListResponseSchema` +- `CustomerDetailResponseSchema` +- `CustomerCreatedResponseSchema` +- `CustomerDeletedResponseSchema` + +--- + +## 2. Products + +### Requests +- `CreateProductCommandSchema` +- `UpdateProductCommandSchema` +- `PatchProductCommandSchema` +- `DeleteProductByIdParamsSchema` +- `GetProductByIdParamsSchema` +- `ProductListCriteriaSchema` +- `SearchProductQuerySchema` + +### Responses +- `ProductSummarySchema` +- `ProductResourceSchema` +- `ProductListResponseSchema` +- `ProductDetailResponseSchema` +- `ProductCreatedResponseSchema` +- `ProductDeletedResponseSchema` + +--- + +## Notas +- Reutilizar esquemas básicos (`EntitySummarySchema`, `EntityResourceSchema`) en respuestas compuestas para cumplir el principio **DRY**. +- **Sufijos**: mantener siempre los sufijos `CommandSchema`, `ParamsSchema`, `QuerySchema`, `CriteriaSchema`, `ListResponseSchema`, `SummarySchema`, `DetailResponseSchema`, `ResourceSchema`, `CreatedResponseSchema`, `DeletedResponseSchema`. +- **Pluralización**: usar singular para el nombre de la entidad en los Schemas (ej. `Customer`, `Product`), incluso si la ruta es plural (`/customers`, `/products`). +- **Carpetas sugeridas**: + modules/ + / + application/ + dto/ + requests/ + responses/ + domain/ + infrastructure/ + api/ diff --git a/modules/core/package.json b/modules/core/package.json index acbec806..c5a456b7 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -11,7 +11,6 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", - "@testing-library/react-hooks": "^8.0.1", "@types/axios": "^0.14.4", "@types/dinero.js": "^1.9.4", "@types/express": "^4.17.21", diff --git a/modules/core/src/common/dto/critera.dto.ts b/modules/core/src/common/dto/critera.dto.ts new file mode 100644 index 00000000..22a289ca --- /dev/null +++ b/modules/core/src/common/dto/critera.dto.ts @@ -0,0 +1,27 @@ +import * as z from "zod/v4"; + +/** +Esquema del objeto normalizado esperado por Criteria.fromPrimitives(...) +No aplica defaults ni correciones: solo valida. +*/ +export const FilterPrimitiveSchema = z.object({ + // Campos mínimos ya normalizados por el conversor + field: z.string(), + operator: z.string(), + value: z.string(), +}); + +export const CriteriaSchema = z.object({ + filters: z.array(FilterPrimitiveSchema), + + // Preferimos omitido; si viene, no puede ser cadena vacía + orderBy: z.string().min(1).optional(), + + order: z.enum(["asc", "desc"]), + + // Ya son números (normalizados); validaciones lógicas + pageSize: z.number().int().min(1, { message: "pageSize must be a positive integer" }), + pageNumber: z.number().int().min(0, { message: "pageNumber must be a non-negative integer" }), +}); + +export type CriteriaDTO = z.infer; diff --git a/modules/core/src/common/dto/index.ts b/modules/core/src/common/dto/index.ts index 87908f38..d119e338 100644 --- a/modules/core/src/common/dto/index.ts +++ b/modules/core/src/common/dto/index.ts @@ -1,3 +1,4 @@ +export * from "./critera.dto"; export * from "./error.dto"; export * from "./list.view.dto"; export * from "./metadata.dto"; diff --git a/modules/core/src/common/dto/list.view.dto.ts b/modules/core/src/common/dto/list.view.dto.ts index 07dc3ae2..05b88d86 100644 --- a/modules/core/src/common/dto/list.view.dto.ts +++ b/modules/core/src/common/dto/list.view.dto.ts @@ -2,12 +2,12 @@ import * as z from "zod/v4"; import { MetadataSchema } from "./metadata.dto"; /** - * Crea un esquema Zod que representa un ListViewDTO genérico. + * Crea un esquema Zod que representa un resultado ListViewDTO genérico. * * @param itemSchema Esquema Zod del elemento T * @returns Zod schema para ListViewDTO */ -export const createListViewSchema = (itemSchema: T) => +export const createListViewResultSchema = (itemSchema: T) => z.object({ page: z.number().int().min(1, "Page must be a positive integer"), per_page: z.number().int().min(1, "Items per page must be a positive integer"), diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index c6ea663c..b3e07d1c 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -10,7 +10,11 @@ "./globals.css": "./src/web/globals.css" }, "peerDependencies": { - "dinero.js": "^1.9.1" + "@erp/core": "workspace:*", + "dinero.js": "^1.9.1", + "express": "^4.18.2", + "sequelize": "^6.37.5", + "zod": "^3.25.67" }, "devDependencies": { "@biomejs/biome": "1.9.4", @@ -27,7 +31,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@erp/core": "workspace:*", "@erp/customers": "workspace:*", "@hookform/resolvers": "^5.0.1", "@repo/rdx-criteria": "workspace:*", @@ -40,7 +43,6 @@ "ag-grid-community": "^33.3.0", "ag-grid-react": "^33.3.0", "date-fns": "^4.1.0", - "express": "^4.18.2", "i18next": "^25.1.1", "lucide-react": "^0.503.0", "react": "^19.1.0", @@ -48,11 +50,9 @@ "react-hook-form": "^7.58.1", "react-i18next": "^15.5.1", "react-router-dom": "^6.26.0", - "sequelize": "^6.37.5", "slugify": "^1.6.6", "sonner": "^2.0.5", "tailwindcss": "^4.1.11", - "tw-animate-css": "^1.3.4", - "zod": "^3.25.67" + "tw-animate-css": "^1.3.4" } } diff --git a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts index 5a6a0f18..f4465bf0 100644 --- a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts @@ -5,7 +5,6 @@ import { CreateCustomerInvoiceCommandSchema, DeleteCustomerInvoiceByIdQuerySchema, DeleteCustomerInvoiceByIdQuerySchema as GetCustomerInvoiceByIdQuerySchema, - ListCustomerInvoicesQuerySchema, } from "../../../common/dto"; import { buildCreateCustomerInvoicesController, @@ -28,7 +27,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => { "/", //checkTabContext, //checkUser, - validateRequest(ListCustomerInvoicesQuerySchema, "params"), + validateRequest(CustomerInvoiceListCriteriaSchema, "params"), (req: Request, res: Response, next: NextFunction) => { buildListCustomerInvoicesController(database).execute(req, res, next); } diff --git a/modules/customer-invoices/src/api/infrastructure/mappers/contact.mapper.ts.bak b/modules/customer-invoices/src/api/infrastructure/mappers/contact.mapper.ts.bak deleted file mode 100644 index 8fdf7ee9..00000000 --- a/modules/customer-invoices/src/api/infrastructure/mappers/contact.mapper.ts.bak +++ /dev/null @@ -1,63 +0,0 @@ -import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure"; -import { Name, TINNumber, UniqueID } from "@shared/contexts"; - -import { Contact, IContactProps } from "../../domain"; -import { IInvoicingContext } from "../InvoicingContext"; -import { Contact_Model, TCreationContact_Model } from "../sequelize/contact.mo.del"; -import { IContactAddressMapper, createContactAddressMapper } from "./contactAddress.mapper"; - -export interface IContactMapper - extends ISequelizeMapper {} - -class ContactMapper - extends SequelizeMapper - implements IContactMapper -{ - public constructor(props: { addressMapper: IContactAddressMapper; context: IInvoicingContext }) { - super(props); - } - - protected toDomainMappingImpl(source: Contact_Model, params: any): Contact { - if (!source.billingAddress) { - this.handleRequiredFieldError( - "billingAddress", - new Error("Missing participant's billing address") - ); - } - - if (!source.shippingAddress) { - this.handleRequiredFieldError( - "shippingAddress", - new Error("Missing participant's shipping address") - ); - } - - const billingAddress = this.props.addressMapper.mapToDomain(source.billingAddress!, params); - - const shippingAddress = this.props.addressMapper.mapToDomain(source.shippingAddress!, params); - - const props: IContactProps = { - tin: this.mapsValue(source, "tin", TINNumber.create), - firstName: this.mapsValue(source, "first_name", Name.create), - lastName: this.mapsValue(source, "last_name", Name.create), - companyName: this.mapsValue(source, "company_name", Name.create), - billingAddress, - shippingAddress, - }; - - const id = this.mapsValue(source, "id", UniqueID.create); - const contactOrError = Contact.create(props, id); - - if (contactOrError.isFailure) { - throw contactOrError.error; - } - - return contactOrError.object; - } -} - -export const createContactMapper = (context: IInvoicingContext): IContactMapper => - new ContactMapper({ - addressMapper: createContactAddressMapper(context), - context, - }); diff --git a/modules/customer-invoices/src/api/infrastructure/mappers/contactAddress.mapper.ts.bak b/modules/customer-invoices/src/api/infrastructure/mappers/contactAddress.mapper.ts.bak deleted file mode 100644 index 4e05d8f8..00000000 --- a/modules/customer-invoices/src/api/infrastructure/mappers/contactAddress.mapper.ts.bak +++ /dev/null @@ -1,65 +0,0 @@ -import { - ISequelizeMapper, - SequelizeMapper, -} from "@/contexts/common/infrastructure"; -import { - City, - Country, - Email, - Note, - Phone, - PostalCode, - Province, - Street, - UniqueID, -} from "@shared/contexts"; -import { ContactAddress, IContactAddressProps } from "../../domain"; -import { IInvoicingContext } from "../InvoicingContext"; -import { - ContactAddress_Model, - TCreationContactAddress_Attributes, -} from "../sequelize"; - -export interface IContactAddressMapper - extends ISequelizeMapper< - ContactAddress_Model, - TCreationContactAddress_Attributes, - ContactAddress - > {} - -export const createContactAddressMapper = ( - context: IInvoicingContext -): IContactAddressMapper => new ContactAddressMapper({ context }); - -class ContactAddressMapper - extends SequelizeMapper< - ContactAddress_Model, - TCreationContactAddress_Attributes, - ContactAddress - > - implements IContactAddressMapper -{ - protected toDomainMappingImpl(source: ContactAddress_Model, params: any) { - const id = this.mapsValue(source, "id", UniqueID.create); - - const props: IContactAddressProps = { - type: source.type, - street: this.mapsValue(source, "street", Street.create), - city: this.mapsValue(source, "city", City.create), - province: this.mapsValue(source, "province", Province.create), - postalCode: this.mapsValue(source, "postal_code", PostalCode.create), - country: this.mapsValue(source, "country", Country.create), - email: this.mapsValue(source, "email", Email.create), - phone: this.mapsValue(source, "phone", Phone.create), - notes: this.mapsValue(source, "notes", Note.create), - }; - - const addressOrError = ContactAddress.create(props, id); - - if (addressOrError.isFailure) { - throw addressOrError.error; - } - - return addressOrError.object; - } -} diff --git a/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts b/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts index 8d5b59b6..ff5734a1 100644 --- a/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts @@ -1,33 +1,8 @@ import * as z from "zod/v4"; -/** - * DTO que transporta los parámetros de la consulta (paginación, filtros, etc.) - * para la búsqueda de facturas de cliente. - * - * Este DTO es utilizado por el endpoint: - * `GET /customer-invoices` (listado / búsqueda de facturas). - * - */ +import { criteriaSchema } from "@erp/core/criteria"; // El esquema genérico validado antes -export const ListCustomerInvoicesQuerySchema = z.object({ - page: z.number().int().min(1).default(1), - pageSize: z.number().int().min(1).max(100).default(25), - fromDate: z - .string() - .optional() - .refine((val) => !val || !Number.isNaN(Date.parse(val)), { - message: "Invalid date format for fromDate", - }), - toDate: z - .string() - .optional() - .refine((val) => !val || !Number.isNaN(Date.parse(val)), { - message: "Invalid date format for toDate", - }), - status: z.enum(["DRAFT", "POSTED", "PAID", "CANCELLED"]).default("DRAFT"), - customerId: z.string().optional(), - sortBy: z.enum(["issueDate", "totalAmount", "number"]).default("issueDate"), - sortDir: z.enum(["ASC", "DESC"]).default("DESC"), -}); +export const CustomerInvoiceListCriteriaSchema = criteriaSchema; +export type CustomerInvoiceListCriteria = z.infer; export type ListCustomerInvoicesQueryDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts b/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts index fe8bded7..451e9b91 100644 --- a/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts @@ -1,7 +1,7 @@ -import { MetadataSchema, createListViewSchema } from "@erp/core"; +import { MetadataSchema, createListViewResultSchema } from "@erp/core"; import * as z from "zod/v4"; -export const ListCustomerInvoicesResultSchema = createListViewSchema( +export const ListCustomerInvoicesResultSchema = createListViewResultSchema( z.object({ id: z.uuid(), invoice_status: z.string(), diff --git a/modules/customers/package.json b/modules/customers/package.json index c523f579..4af1fbed 100644 --- a/modules/customers/package.json +++ b/modules/customers/package.json @@ -10,8 +10,17 @@ "./globals.css": "./src/web/globals.css", "./components": "./src/web/components/index.ts" }, + "peerDependencies": { + "@erp/core": "workspace:*", + "dinero.js": "^1.9.1", + "express": "^4.18.2", + "sequelize": "^6.37.5", + "zod": "^3.25.67" + }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@hookform/devtools": "^4.4.0", + "@types/dinero.js": "^1.9.4", "@types/express": "^4.17.21", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.3", @@ -20,7 +29,10 @@ }, "dependencies": { "@ag-grid-community/locale": "34.0.0", - "@erp/core": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@erp/customers": "workspace:*", "@hookform/resolvers": "^5.0.1", "@repo/rdx-criteria": "workspace:*", "@repo/rdx-ddd": "workspace:*", @@ -28,24 +40,20 @@ "@repo/rdx-utils": "workspace:*", "@repo/shadcn-ui": "workspace:*", "@tanstack/react-query": "^5.74.11", + "@tanstack/react-table": "^8.21.3", "ag-grid-community": "^33.3.0", "ag-grid-react": "^33.3.0", "date-fns": "^4.1.0", - "express": "^4.18.2", "i18next": "^25.1.1", "lucide-react": "^0.503.0", "react": "^19.1.0", - "react-data-table-component": "^7.7.0", "react-dom": "^19.1.0", "react-hook-form": "^7.58.1", "react-i18next": "^15.5.1", "react-router-dom": "^6.26.0", - "sequelize": "^6.37.5", "slugify": "^1.6.6", + "sonner": "^2.0.5", "tailwindcss": "^4.1.11", - "tw-animate-css": "^1.3.5", - "use-debounce": "^10.0.5", - "use-query": "^1.0.2", - "zod": "^3.25.67" + "tw-animate-css": "^1.3.4" } } diff --git a/modules/customers/src/api/application/create-customer/create-customer.use-case.ts b/modules/customers/src/api/application/create-customer/create-customer.use-case.ts new file mode 100644 index 00000000..33c71e2f --- /dev/null +++ b/modules/customers/src/api/application/create-customer/create-customer.use-case.ts @@ -0,0 +1,57 @@ +import { DuplicateEntityError, ITransactionManager } from "@erp/core/api"; +import { CreateCustomerCommandDTO } from "@erp/customers/common/dto"; +import { Result } from "@repo/rdx-utils"; +import { Transaction } from "sequelize"; +import { ICustomerService } from "../../domain"; +import { mapDTOToCustomerProps } from "../helpers"; +import { CreateCustomersPresenter } from "./presenter"; + +export class CreateCustomerUseCase { + constructor( + private readonly service: ICustomerService, + private readonly transactionManager: ITransactionManager, + private readonly presenter: CreateCustomersPresenter + ) {} + + public execute(dto: CreateCustomerCommandDTO) { + const invoicePropsOrError = mapDTOToCustomerProps(dto); + + if (invoicePropsOrError.isFailure) { + return Result.fail(invoicePropsOrError.error); + } + + const { props, id } = invoicePropsOrError.data; + + const invoiceOrError = this.service.build(props, id); + + if (invoiceOrError.isFailure) { + return Result.fail(invoiceOrError.error); + } + + const newInvoice = invoiceOrError.data; + + return this.transactionManager.complete(async (transaction: Transaction) => { + try { + const duplicateCheck = await this.service.existsById(id, transaction); + + if (duplicateCheck.isFailure) { + return Result.fail(duplicateCheck.error); + } + + if (duplicateCheck.data) { + return Result.fail(new DuplicateEntityError("Customer", id.toString())); + } + + const result = await this.service.save(newInvoice, transaction); + if (result.isFailure) { + return Result.fail(result.error); + } + + const viewDTO = this.presenter.toDTO(newInvoice); + return Result.ok(viewDTO); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customers/src/api/application/create-customer/index.ts b/modules/customers/src/api/application/create-customer/index.ts new file mode 100644 index 00000000..12c6e7bf --- /dev/null +++ b/modules/customers/src/api/application/create-customer/index.ts @@ -0,0 +1,2 @@ +export * from "./create-customer.use-case"; +export * from "./presenter"; diff --git a/modules/customers/src/api/application/create-customer/presenter/create-customers.presenter.ts b/modules/customers/src/api/application/create-customer/presenter/create-customers.presenter.ts new file mode 100644 index 00000000..63fb4676 --- /dev/null +++ b/modules/customers/src/api/application/create-customer/presenter/create-customers.presenter.ts @@ -0,0 +1,27 @@ +import { Customer } from "@erp/customers/api/domain"; +import { CustomersCreationResultDTO } from "@erp/customers/common/dto"; + +export class CreateCustomersPresenter { + public toDTO(invoice: Customer): CustomersCreationResultDTO { + return { + id: invoice.id.toPrimitive(), + + invoice_status: invoice.status.toString(), + invoice_number: invoice.invoiceNumber.toString(), + invoice_series: invoice.invoiceSeries.toString(), + issue_date: invoice.issueDate.toISOString(), + operation_date: invoice.operationDate.toISOString(), + language_code: "ES", + currency: "EUR", + + //subtotal_price: invoice.calculateSubtotal().toPrimitive(), + //total_price: invoice.calculateTotal().toPrimitive(), + + //recipient: CustomerParticipantPresenter(customer.recipient), + + metadata: { + entity: "customer", + }, + }; + } +} diff --git a/modules/customers/src/api/application/create-customer/presenter/index.ts b/modules/customers/src/api/application/create-customer/presenter/index.ts new file mode 100644 index 00000000..e63b640f --- /dev/null +++ b/modules/customers/src/api/application/create-customer/presenter/index.ts @@ -0,0 +1 @@ +export * from "./create-customers.presenter"; diff --git a/modules/customers/src/api/application/delete-customer/delete-customer.use-case.ts b/modules/customers/src/api/application/delete-customer/delete-customer.use-case.ts new file mode 100644 index 00000000..d566f1d3 --- /dev/null +++ b/modules/customers/src/api/application/delete-customer/delete-customer.use-case.ts @@ -0,0 +1,40 @@ +import { EntityNotFoundError, ITransactionManager } from "@erp/core/api"; +import { DeleteCustomerByIdQueryDTO } from "@erp/customers/common/dto"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { ICustomerService } from "../../domain"; + +export class DeleteCustomerUseCase { + constructor( + private readonly service: ICustomerService, + private readonly transactionManager: ITransactionManager + ) {} + + public execute(dto: DeleteCustomerByIdQueryDTO) { + const idOrError = UniqueID.create(dto.id); + + if (idOrError.isFailure) { + return Result.fail(idOrError.error); + } + + const id = idOrError.data; + + return this.transactionManager.complete(async (transaction) => { + try { + const existsCheck = await this.service.existsById(id, transaction); + + if (existsCheck.isFailure) { + return Result.fail(existsCheck.error); + } + + if (!existsCheck.data) { + return Result.fail(new EntityNotFoundError("Customer", id.toString())); + } + + return await this.service.deleteById(id, transaction); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customers/src/api/application/delete-customer/index.ts b/modules/customers/src/api/application/delete-customer/index.ts new file mode 100644 index 00000000..3280b04b --- /dev/null +++ b/modules/customers/src/api/application/delete-customer/index.ts @@ -0,0 +1 @@ +export * from "./delete-customer.use-case"; diff --git a/modules/customers/src/api/application/get-customer/get-customer.use-case.ts b/modules/customers/src/api/application/get-customer/get-customer.use-case.ts new file mode 100644 index 00000000..f21551a5 --- /dev/null +++ b/modules/customers/src/api/application/get-customer/get-customer.use-case.ts @@ -0,0 +1,36 @@ +import { ITransactionManager } from "@erp/core/api"; +import { GetCustomerByIdQueryDTO } from "@erp/customers/common/dto"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { ICustomerService } from "../../domain"; +import { GetCustomerPresenter } from "./presenter"; + +export class GetCustomerUseCase { + constructor( + private readonly service: ICustomerService, + private readonly transactionManager: ITransactionManager, + private readonly presenter: GetCustomerPresenter + ) {} + + public execute(dto: GetCustomerByIdQueryDTO) { + const idOrError = UniqueID.create(dto.id); + + if (idOrError.isFailure) { + return Result.fail(idOrError.error); + } + + return this.transactionManager.complete(async (transaction) => { + try { + const invoiceOrError = await this.service.getById(idOrError.data, transaction); + if (invoiceOrError.isFailure) { + return Result.fail(invoiceOrError.error); + } + + const getDTO = this.presenter.toDTO(invoiceOrError.data); + return Result.ok(getDTO); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customers/src/api/application/get-customer/index.ts b/modules/customers/src/api/application/get-customer/index.ts new file mode 100644 index 00000000..cc3e68e8 --- /dev/null +++ b/modules/customers/src/api/application/get-customer/index.ts @@ -0,0 +1,2 @@ +export * from "./get-customer.use-case"; +export * from "./presenter"; diff --git a/modules/customers/src/api/application/get-customer/presenter/InvoiceItem.presenter.ts.bak b/modules/customers/src/api/application/get-customer/presenter/InvoiceItem.presenter.ts.bak new file mode 100644 index 00000000..3a6e2c6e --- /dev/null +++ b/modules/customers/src/api/application/get-customer/presenter/InvoiceItem.presenter.ts.bak @@ -0,0 +1,16 @@ +import { CustomerItem } from "#/server/domain"; +import { IInvoicingContext } from "#/server/intrastructure"; +import { Collection } from "@rdx/core"; + +export const customerItemPresenter = (items: Collection, context: IInvoicingContext) => + items.totalCount > 0 + ? items.items.map((item: CustomerItem) => ({ + description: item.description.toString(), + quantity: item.quantity.toString(), + unit_measure: "", + unit_price: item.unitPrice.toPrimitive() as IMoney_Response_DTO, + subtotal: item.calculateSubtotal().toPrimitive() as IMoney_Response_DTO, + tax_amount: item.calculateTaxAmount().toPrimitive() as IMoney_Response_DTO, + total: item.calculateTotal().toPrimitive() as IMoney_Response_DTO, + })) + : []; diff --git a/modules/customers/src/api/application/get-customer/presenter/InvoiceParticipant.presenter.ts.bak b/modules/customers/src/api/application/get-customer/presenter/InvoiceParticipant.presenter.ts.bak new file mode 100644 index 00000000..d10d3001 --- /dev/null +++ b/modules/customers/src/api/application/get-customer/presenter/InvoiceParticipant.presenter.ts.bak @@ -0,0 +1,26 @@ +import { ICustomerParticipant } from "@/contexts/invoicing/domain"; +import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext"; +import { ICreateCustomer_Participant_Response_DTO } from "@shared/contexts"; +import { CustomerParticipantAddressPresenter } from "./CustomerParticipantAddress.presenter"; + +export const CustomerParticipantPresenter = async ( + participant: ICustomerParticipant, + context: IInvoicingContext, +): Promise => { + return { + id: participant.id.toString(), + tin: participant.tin.toString(), + first_name: participant.firstName.toString(), + last_name: participant.lastName.toString(), + company_name: participant.companyName.toString(), + + billing_address: await CustomerParticipantAddressPresenter( + participant.billingAddress!, + context, + ), + shipping_address: await CustomerParticipantAddressPresenter( + participant.shippingAddress!, + context, + ), + }; +}; diff --git a/modules/customers/src/api/application/get-customer/presenter/InvoiceParticipantAddress.presenter.ts.bak b/modules/customers/src/api/application/get-customer/presenter/InvoiceParticipantAddress.presenter.ts.bak new file mode 100644 index 00000000..6266fda0 --- /dev/null +++ b/modules/customers/src/api/application/get-customer/presenter/InvoiceParticipantAddress.presenter.ts.bak @@ -0,0 +1,19 @@ +import { CustomerParticipantAddress } from "@/contexts/invoicing/domain"; +import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext"; +import { ICreateCustomer_AddressParticipant_Response_DTO } from "@shared/contexts"; + +export const CustomerParticipantAddressPresenter = async ( + address: CustomerParticipantAddress, + context: IInvoicingContext, +): Promise => { + return { + id: address.id.toString(), + street: address.street.toString(), + city: address.city.toString(), + postal_code: address.postalCode.toString(), + province: address.province.toString(), + country: address.country.toString(), + email: address.email.toString(), + phone: address.phone.toString(), + }; +}; diff --git a/modules/customers/src/api/application/get-customer/presenter/get-invoice.presenter.ts b/modules/customers/src/api/application/get-customer/presenter/get-invoice.presenter.ts new file mode 100644 index 00000000..a312c847 --- /dev/null +++ b/modules/customers/src/api/application/get-customer/presenter/get-invoice.presenter.ts @@ -0,0 +1,65 @@ +import { GetCustomerByIdResultDTO } from "../../../../common/dto"; +import { Customer } from "../../../domain"; + +export interface GetCustomerPresenter { + toDTO: (customer: Customer) => GetCustomerByIdResultDTO; +} + +export const getCustomerPresenter: GetCustomerPresenter = { + toDTO: (customer: Customer): GetCustomerByIdResultDTO => ({ + id: customer.id.toPrimitive(), + + invoice_status: customer.status.toString(), + invoice_number: customer.invoiceNumber.toString(), + invoice_series: customer.invoiceSeries.toString(), + issue_date: customer.issueDate.toDateString(), + operation_date: customer.operationDate.toDateString(), + language_code: "ES", + currency: customer.currency, + + metadata: { + entity: "customers", + }, + + //subtotal: customer.calculateSubtotal().toPrimitive(), + + //total: customer.calculateTotal().toPrimitive(), + + /*items: + customer.items.size() > 0 + ? customer.items.map((item: CustomerItem) => ({ + description: item.description.toString(), + quantity: item.quantity.toPrimitive(), + unit_measure: "", + unit_price: item.unitPrice.toPrimitive(), + subtotal: item.calculateSubtotal().toPrimitive(), + //tax_amount: item.calculateTaxAmount().toPrimitive(), + total: item.calculateTotal().toPrimitive(), + })) + : [],*/ + + //sender: {}, //await CustomerParticipantPresenter(customer.senderId, context), + + /*recipient: await CustomerParticipantPresenter(customer.recipient, context), + items: customerItemPresenter(customer.items, context), + + payment_term: { + payment_type: "", + due_date: "", + }, + + due_amount: { + currency: customer.currency.toString(), + precision: 2, + amount: 0, + }, + + custom_fields: [], + + metadata: { + create_time: "", + last_updated_time: "", + delete_time: "", + },*/ + }), +}; diff --git a/modules/customers/src/api/application/get-customer/presenter/index.ts b/modules/customers/src/api/application/get-customer/presenter/index.ts new file mode 100644 index 00000000..60624c19 --- /dev/null +++ b/modules/customers/src/api/application/get-customer/presenter/index.ts @@ -0,0 +1 @@ +export * from "./get-invoice.presenter"; diff --git a/modules/customers/src/api/application/helpers/extract-or-push-error.ts b/modules/customers/src/api/application/helpers/extract-or-push-error.ts new file mode 100644 index 00000000..809eb671 --- /dev/null +++ b/modules/customers/src/api/application/helpers/extract-or-push-error.ts @@ -0,0 +1,45 @@ +import { DomainValidationError, ValidationErrorDetail } from "@erp/core/api"; +import { Result } from "@repo/rdx-utils"; + +/** + * Extrae un valor de un Result si es válido. + * Si es un fallo, agrega un ValidationErrorDetail al array proporcionado. + * @param result - El resultado a evaluar. + * @param path - La ruta del error para el detalle de validación. + * @param errors - El array donde se agregarán los errores de validación. + * @returns El valor extraído si el resultado es exitoso, o undefined si es un fallo. + * @template T - El tipo de dato esperado en el resultado exitoso. + * @throws {Error} Si el resultado es un fallo y no es una instancia de DomainValidationError. + * @example + * const result = Result.ok(42); + * const value = extractOrPushError(result, 'some.path', []); + * console.log(value); // 42 + * const errorResult = Result.fail(new Error('Something went wrong')); + * const value = extractOrPushError(errorResult, 'some.path', []); + * console.log(value); // undefined + * // errors will contain [{ path: 'some.path', message: 'Something went wrong' }] + * + * @see Result + * @see DomainValidationError + * @see ValidationErrorDetail + + */ +export function extractOrPushError( + result: Result, + path: string, + errors: ValidationErrorDetail[] +): T | undefined { + if (result.isFailure) { + const error = result.error; + + if (error instanceof DomainValidationError) { + errors.push({ path, message: error.detail }); + } else { + errors.push({ path, message: error.message }); + } + + return undefined; + } + + return result.data; +} diff --git a/modules/customers/src/api/application/helpers/has-no-undefined-fields.ts b/modules/customers/src/api/application/helpers/has-no-undefined-fields.ts new file mode 100644 index 00000000..beabd781 --- /dev/null +++ b/modules/customers/src/api/application/helpers/has-no-undefined-fields.ts @@ -0,0 +1,50 @@ +/** + * + * @param obj - El objeto a evaluar. + * @template T - El tipo del objeto. + * @description Verifica si un objeto no tiene campos con valor undefined. + * + * Esta función recorre los valores del objeto y devuelve true si todos los valores son diferentes de undefined. + * Si al menos un valor es undefined, devuelve false. + * + * @example + * const obj = { a: 1, b: 'test', c: null }; + * console.log(hasNoUndefinedFields(obj)); // true + * + * const objWithUndefined = { a: 1, b: undefined, c: null }; + * console.log(hasNoUndefinedFields(objWithUndefined)); // false + * + * @template T - El tipo del objeto. + * @param obj - El objeto a evaluar. + * @returns true si el objeto no tiene campos undefined, false en caso contrario. + */ + +export function hasNoUndefinedFields>( + obj: T +): obj is { [K in keyof T]-?: Exclude } { + return Object.values(obj).every((value) => value !== undefined); +} + +/** + * + * @description Verifica si un objeto tiene campos con valor undefined. + * Esta función es el complemento de `hasNoUndefinedFields`. + * + * @example + * const obj = { a: 1, b: 'test', c: null }; + * console.log(hasUndefinedFields(obj)); // false + * + * const objWithUndefined = { a: 1, b: undefined, c: null }; + * console.log(hasUndefinedFields(objWithUndefined)); // true + * + * @template T - El tipo del objeto. + * @param obj - El objeto a evaluar. + * @returns true si el objeto tiene al menos un campo undefined, false en caso contrario. + * + */ + +export function hasUndefinedFields>( + obj: T +): obj is { [K in keyof T]-?: Exclude } { + return !hasNoUndefinedFields(obj); +} diff --git a/modules/customers/src/api/application/helpers/index.ts b/modules/customers/src/api/application/helpers/index.ts new file mode 100644 index 00000000..2de24716 --- /dev/null +++ b/modules/customers/src/api/application/helpers/index.ts @@ -0,0 +1 @@ +export * from "./map-dto-to-customer-props"; diff --git a/modules/customers/src/api/application/helpers/map-dto-to-customer-items-props.ts b/modules/customers/src/api/application/helpers/map-dto-to-customer-items-props.ts new file mode 100644 index 00000000..0f4e76cf --- /dev/null +++ b/modules/customers/src/api/application/helpers/map-dto-to-customer-items-props.ts @@ -0,0 +1,83 @@ +import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api"; +import { CreateCustomerCommandDTO } from "@erp/customers/common/dto"; +import { Result } from "@repo/rdx-utils"; +import { + CustomerItem, + CustomerItemDescription, + CustomerItemDiscount, + CustomerItemQuantity, + CustomerItemUnitPrice, +} from "../../domain"; +import { extractOrPushError } from "./extract-or-push-error"; +import { hasNoUndefinedFields } from "./has-no-undefined-fields"; + +export function mapDTOToCustomerItemsProps( + dtoItems: Pick["items"] +): Result { + const errors: ValidationErrorDetail[] = []; + const items: CustomerItem[] = []; + + dtoItems.forEach((item, index) => { + const path = (field: string) => `items[${index}].${field}`; + + const description = extractOrPushError( + CustomerItemDescription.create(item.description), + path("description"), + errors + ); + + const quantity = extractOrPushError( + CustomerItemQuantity.create({ + amount: item.quantity.amount, + scale: item.quantity.scale, + }), + path("quantity"), + errors + ); + + const unitPrice = extractOrPushError( + CustomerItemUnitPrice.create({ + amount: item.unitPrice.amount, + scale: item.unitPrice.scale, + currency_code: item.unitPrice.currency, + }), + path("unit_price"), + errors + ); + + const discount = extractOrPushError( + CustomerItemDiscount.create({ + amount: item.discount.amount, + scale: item.discount.scale, + }), + path("discount"), + errors + ); + + if (errors.length === 0) { + const itemProps = { + description: description, + quantity: quantity, + unitPrice: unitPrice, + discount: discount, + }; + + if (hasNoUndefinedFields(itemProps)) { + // Validar y crear el item de factura + const itemOrError = CustomerItem.create(itemProps); + + if (itemOrError.isSuccess) { + items.push(itemOrError.data); + } else { + errors.push({ path: `items[${index}]`, message: itemOrError.error.message }); + } + } + } + + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection(errors)); + } + }); + + return Result.ok(items); +} diff --git a/modules/customers/src/api/application/helpers/map-dto-to-customer-props.ts b/modules/customers/src/api/application/helpers/map-dto-to-customer-props.ts new file mode 100644 index 00000000..1806f23b --- /dev/null +++ b/modules/customers/src/api/application/helpers/map-dto-to-customer-props.ts @@ -0,0 +1,78 @@ +import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api"; +import { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { CreateCustomerCommandDTO } from "../../../common/dto"; +import { CustomerNumber, CustomerProps, CustomerSerie, CustomerStatus } from "../../domain"; +import { extractOrPushError } from "./extract-or-push-error"; +import { mapDTOToCustomerItemsProps } from "./map-dto-to-customer-items-props"; + +/** + * Convierte el DTO a las props validadas (CustomerProps). + * No construye directamente el agregado. + * + * @param dto - DTO con los datos de la factura de cliente + * @returns + + * + */ + +export function mapDTOToCustomerProps(dto: CreateCustomerCommandDTO) { + const errors: ValidationErrorDetail[] = []; + + const invoiceId = extractOrPushError(UniqueID.create(dto.id), "id", errors); + + const invoiceNumber = extractOrPushError( + CustomerNumber.create(dto.invoice_number), + "invoice_number", + errors + ); + const invoiceSeries = extractOrPushError( + CustomerSerie.create(dto.invoice_series), + "invoice_series", + errors + ); + const issueDate = extractOrPushError(UtcDate.createFromISO(dto.issue_date), "issue_date", errors); + const operationDate = extractOrPushError( + UtcDate.createFromISO(dto.operation_date), + "operation_date", + errors + ); + + //const currency = extractOrPushError(Currency.(dto.currency), "currency", errors); + const currency = dto.currency; + + // 🔄 Validar y construir los items de factura con helper especializado + const itemsResult = mapDTOToCustomerItemsProps(dto.items); + if (itemsResult.isFailure) { + return Result.fail(itemsResult.error); + } + + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection(errors)); + } + + const invoiceProps: CustomerProps = { + invoiceNumber: invoiceNumber!, + invoiceSeries: invoiceSeries!, + issueDate: issueDate!, + operationDate: operationDate!, + status: CustomerStatus.createDraft(), + currency, + }; + + return Result.ok({ id: invoiceId!, props: invoiceProps }); + + /*if (hasNoUndefinedFields(invoiceProps)) { + const invoiceOrError = Customer.create(invoiceProps, invoiceId); + if (invoiceOrError.isFailure) { + return Result.fail(invoiceOrError.error); + } + return Result.ok(invoiceOrError.data); + } + + return Result.fail( + new ValidationErrorCollection([ + { path: "", message: "Error building from DTO: Some fields are undefined" }, + ]) + );*/ +} diff --git a/modules/customers/src/api/application/index.ts b/modules/customers/src/api/application/index.ts new file mode 100644 index 00000000..847c0981 --- /dev/null +++ b/modules/customers/src/api/application/index.ts @@ -0,0 +1,5 @@ +export * from "./create-customer"; +export * from "./delete-customer"; +export * from "./get-customer"; +export * from "./list-customers"; +//export * from "./update-customer"; diff --git a/modules/customers/src/api/application/list-customers/index.ts b/modules/customers/src/api/application/list-customers/index.ts new file mode 100644 index 00000000..052600c9 --- /dev/null +++ b/modules/customers/src/api/application/list-customers/index.ts @@ -0,0 +1 @@ +export * from "./list-customers.use-case"; diff --git a/modules/customers/src/api/application/list-customers/list-customers.use-case.ts b/modules/customers/src/api/application/list-customers/list-customers.use-case.ts new file mode 100644 index 00000000..48e645b0 --- /dev/null +++ b/modules/customers/src/api/application/list-customers/list-customers.use-case.ts @@ -0,0 +1,33 @@ +import { ITransactionManager } from "@erp/core/api"; +import { ListCustomersResultDTO } from "@erp/customers/common/dto"; +import { Criteria } from "@repo/rdx-criteria/server"; +import { Result } from "@repo/rdx-utils"; +import { Transaction } from "sequelize"; +import { ICustomerService } from "../../domain"; +import { ListCustomersPresenter } from "./presenter"; + +export class ListCustomersUseCase { + constructor( + private readonly customerService: ICustomerService, + private readonly transactionManager: ITransactionManager, + private readonly presenter: ListCustomersPresenter + ) {} + + public execute(criteria: Criteria): Promise> { + return this.transactionManager.complete(async (transaction: Transaction) => { + try { + const result = await this.customerService.findByCriteria(criteria, transaction); + + if (result.isFailure) { + console.error(result.error); + return Result.fail(result.error); + } + + const dto: ListCustomersResultDTO = this.presenter.toDTO(result.data, criteria); + return Result.ok(dto); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customers/src/api/application/list-customers/presenter/index.ts b/modules/customers/src/api/application/list-customers/presenter/index.ts new file mode 100644 index 00000000..9ecb5c89 --- /dev/null +++ b/modules/customers/src/api/application/list-customers/presenter/index.ts @@ -0,0 +1 @@ +export * from "./list-invoices.presenter"; diff --git a/modules/customers/src/api/application/list-customers/presenter/list-invoices.presenter.ts b/modules/customers/src/api/application/list-customers/presenter/list-invoices.presenter.ts new file mode 100644 index 00000000..6c4770b6 --- /dev/null +++ b/modules/customers/src/api/application/list-customers/presenter/list-invoices.presenter.ts @@ -0,0 +1,54 @@ +import { Criteria } from "@repo/rdx-criteria/server"; +import { Collection } from "@repo/rdx-utils"; +import { CustomerListResponsetDTO } from "../../../../common/dto"; +import { Customer } from "../../../domain"; + +export interface ListCustomersPresenter { + toDTO: (customers: Collection, criteria: Criteria) => CustomerListResponsetDTO; +} + +export const listCustomersPresenter: ListCustomersPresenter = { + toDTO: (customers: Collection, criteria: Criteria): CustomerListResponsetDTO => { + const items = customers.map((invoice) => { + return { + id: invoice.id.toPrimitive(), + + invoice_status: invoice.status.toString(), + invoice_number: invoice.invoiceNumber.toString(), + invoice_series: invoice.invoiceSeries.toString(), + issue_date: invoice.issueDate.toISOString(), + operation_date: invoice.operationDate.toISOString(), + language_code: "ES", + currency: "EUR", + + subtotal_price: invoice.calculateSubtotal().toPrimitive(), + total_price: invoice.calculateTotal().toPrimitive(), + + //recipient: CustomerParticipantPresenter(customer.recipient), + + metadata: { + entity: "customer", + }, + }; + }); + + const totalItems = customers.total(); + + return { + page: criteria.pageNumber, + per_page: criteria.pageSize, + total_pages: Math.ceil(totalItems / criteria.pageSize), + total_items: totalItems, + items: items, + metadata: { + entity: "customers", + criteria: criteria.toJSON(), + //links: { + // self: `/api/customers?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`, + // first: `/api/customers?page=1&per_page=${criteria.pageSize}`, + // last: `/api/customers?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`, + //}, + }, + }; + }, +}; diff --git a/modules/customers/src/api/application/services/index.ts b/modules/customers/src/api/application/services/index.ts new file mode 100644 index 00000000..4510ef9a --- /dev/null +++ b/modules/customers/src/api/application/services/index.ts @@ -0,0 +1,2 @@ +//export * from "./participantAddressFinder"; +//export * from "./participantFinder"; diff --git a/modules/customers/src/api/application/services/participantAddressFinder.ts b/modules/customers/src/api/application/services/participantAddressFinder.ts new file mode 100644 index 00000000..3265aa7a --- /dev/null +++ b/modules/customers/src/api/application/services/participantAddressFinder.ts @@ -0,0 +1,64 @@ +/* import { + ApplicationServiceError, + type IApplicationServiceError, +} from "@/contexts/common/application/services/ApplicationServiceError"; +import { IAdapter, RepositoryBuilder } from "@/contexts/common/domain"; +import { Result, UniqueID } from "@shared/contexts"; +import { NullOr } from "@shared/utilities"; +import { ICustomerParticipantAddress, ICustomerParticipantAddressRepository } from "../../domain"; + +export const participantAddressFinder = async ( + addressId: UniqueID, + adapter: IAdapter, + repository: RepositoryBuilder +) => { + if (addressId.isNull()) { + return Result.fail( + ApplicationServiceError.create( + ApplicationServiceError.INVALID_REQUEST_PARAM, + `Participant address ID required` + ) + ); + } + + const transaction = adapter.startTransaction(); + let address: NullOr = null; + + try { + await transaction.complete(async (t) => { + address = await repository({ transaction: t }).getById(addressId); + }); + + if (address === null) { + return Result.fail( + ApplicationServiceError.create(ApplicationServiceError.NOT_FOUND_ERROR, "", { + id: addressId.toString(), + entity: "participant address", + }) + ); + } + + return Result.ok(address); + } catch (error: unknown) { + const _error = error as Error; + + if (repository().isRepositoryError(_error)) { + return Result.fail( + ApplicationServiceError.create( + ApplicationServiceError.REPOSITORY_ERROR, + _error.message, + _error + ) + ); + } + + return Result.fail( + ApplicationServiceError.create( + ApplicationServiceError.UNEXCEPTED_ERROR, + _error.message, + _error + ) + ); + } +}; + */ diff --git a/modules/customers/src/api/application/services/participantFinder.ts b/modules/customers/src/api/application/services/participantFinder.ts new file mode 100644 index 00000000..df238b2c --- /dev/null +++ b/modules/customers/src/api/application/services/participantFinder.ts @@ -0,0 +1,21 @@ +/* import { IAdapter, RepositoryBuilder } from "@/contexts/common/domain"; +import { UniqueID } from "@shared/contexts"; +import { ICustomerParticipantRepository } from "../../domain"; +import { CustomerCustomer } from "../../domain/entities/customer-customer/customer-customer"; + +export const participantFinder = async ( + participantId: UniqueID, + adapter: IAdapter, + repository: RepositoryBuilder +): Promise => { + if (!participantId || (participantId && participantId.isNull())) { + return Promise.resolve(undefined); + } + + const participant = await adapter + .startTransaction() + .complete((t) => repository({ transaction: t }).getById(participantId)); + + return Promise.resolve(participant ? participant : undefined); +}; + */ diff --git a/modules/customers/src/api/application/update-customer/index.ts b/modules/customers/src/api/application/update-customer/index.ts new file mode 100644 index 00000000..db3e8d69 --- /dev/null +++ b/modules/customers/src/api/application/update-customer/index.ts @@ -0,0 +1 @@ +export * from "./update-customer.use-case"; diff --git a/modules/customers/src/api/application/update-customer/update-customer.use-case.ts b/modules/customers/src/api/application/update-customer/update-customer.use-case.ts new file mode 100644 index 00000000..5f2bf96d --- /dev/null +++ b/modules/customers/src/api/application/update-customer/update-customer.use-case.ts @@ -0,0 +1,401 @@ +import { UniqueID } from "@/core/common/domain"; +import { ITransactionManager } from "@/core/common/infrastructure/database"; +import { Result } from "@repo/rdx-utils"; +import { IUpdateCustomerRequestDTO } from "../../common/dto"; +import { Customer, ICustomerService } from "../domain"; + +export class CreateCustomerUseCase { + constructor( + private readonly customerService: ICustomerService, + private readonly transactionManager: ITransactionManager + ) {} + + public execute( + customerID: UniqueID, + dto: Partial + ): Promise> { + return this.transactionManager.complete(async (transaction) => { + return Result.fail(new Error("No implementado")); + /* + try { + const validOrErrors = this.validateCustomerData(dto); + if (validOrErrors.isFailure) { + return Result.fail(validOrErrors.error); + } + + const data = validOrErrors.data; + + // Update customer with dto + return await this.customerService.updateCustomerById(customerID, data, transaction); + } catch (error: unknown) { + logger.error(error as Error); + return Result.fail(error as Error); + } + */ + }); + } + + /* private validateCustomerData( + dto: Partial + ): Result, Error> { + const errors: Error[] = []; + const validatedData: Partial = {}; + + // Create customer + let customer_status = CustomerStatus.create(customerDTO.status).object; + if (customer_status.isEmpty()) { + customer_status = CustomerStatus.createDraft(); + } + + let customer_series = CustomerSeries.create(customerDTO.customer_series).object; + if (customer_series.isEmpty()) { + customer_series = CustomerSeries.create(customerDTO.customer_series).object; + } + + let issue_date = CustomerDate.create(customerDTO.issue_date).object; + if (issue_date.isEmpty()) { + issue_date = CustomerDate.createCurrentDate().object; + } + + let operation_date = CustomerDate.create(customerDTO.operation_date).object; + if (operation_date.isEmpty()) { + operation_date = CustomerDate.createCurrentDate().object; + } + + let customerCurrency = Currency.createFromCode(customerDTO.currency).object; + + if (customerCurrency.isEmpty()) { + customerCurrency = Currency.createDefaultCode().object; + } + + let customerLanguage = Language.createFromCode(customerDTO.language_code).object; + + if (customerLanguage.isEmpty()) { + customerLanguage = Language.createDefaultCode().object; + } + + const items = new Collection( + customerDTO.items?.map( + (item) => + CustomerSimpleItem.create({ + description: Description.create(item.description).object, + quantity: Quantity.create(item.quantity).object, + unitPrice: UnitPrice.create({ + amount: item.unit_price.amount, + currencyCode: item.unit_price.currency, + precision: item.unit_price.precision, + }).object, + }).object + ) + ); + + if (!customer_status.isDraft()) { + throw Error("Error al crear una factura que no es borrador"); + } + + return DraftCustomer.create( + { + customerSeries: customer_series, + issueDate: issue_date, + operationDate: operation_date, + customerCurrency, + language: customerLanguage, + customerNumber: CustomerNumber.create(undefined).object, + //notes: Note.create(customerDTO.notes).object, + + //senderId: UniqueID.create(null).object, + recipient, + + items, + }, + customerId + ); + } */ +} + +/* export type UpdateCustomerResponseOrError = + | Result // Misc errors (value objects) + | Result; // Success! + +export class UpdateCustomerUseCase2 + implements + IUseCase<{ id: UniqueID; data: IUpdateCustomer_DTO }, Promise> +{ + private _context: IInvoicingContext; + private _adapter: ISequelizeAdapter; + private _repositoryManager: IRepositoryManager; + + constructor(context: IInvoicingContext) { + this._context = context; + this._adapter = context.adapter; + this._repositoryManager = context.repositoryManager; + } + + private getRepository(name: string) { + return this._repositoryManager.getRepository(name); + } + + private handleValidationFailure( + validationError: Error, + message?: string + ): Result { + return Result.fail( + UseCaseError.create( + UseCaseError.INVALID_INPUT_DATA, + message ? message : validationError.message, + validationError + ) + ); + } + + async execute(request: { + id: UniqueID; + data: IUpdateCustomer_DTO; + }): Promise { + const { id, data: customerDTO } = request; + + // Validaciones + const customerDTOOrError = ensureUpdateCustomer_DTOIsValid(customerDTO); + if (customerDTOOrError.isFailure) { + return this.handleValidationFailure(customerDTOOrError.error); + } + + const transaction = this._adapter.startTransaction(); + + const customerRepoBuilder = this.getRepository("Customer"); + + let customer: Customer | null = null; + + try { + await transaction.complete(async (t) => { + customer = await customerRepoBuilder({ transaction: t }).getById(id); + }); + + if (customer === null) { + return Result.fail( + UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, `Customer not found`, { + id: request.id.toString(), + entity: "customer", + }) + ); + } + + return Result.ok(customer); + } catch (error: unknown) { + const _error = error as Error; + if (customerRepoBuilder().isRepositoryError(_error)) { + return this.handleRepositoryError(error as BaseError, customerRepoBuilder()); + } else { + return this.handleUnexceptedError(error); + } + } + + // Recipient validations + const recipientIdOrError = ensureParticipantIdIsValid( + customerDTO?.recipient?.id, + ); + if (recipientIdOrError.isFailure) { + return this.handleValidationFailure( + recipientIdOrError.error, + "Recipient ID not valid", + ); + } + const recipientId = recipientIdOrError.object; + + const recipientBillingIdOrError = ensureParticipantAddressIdIsValid( + customerDTO?.recipient?.billing_address_id, + ); + if (recipientBillingIdOrError.isFailure) { + return this.handleValidationFailure( + recipientBillingIdOrError.error, + "Recipient billing address ID not valid", + ); + } + const recipientBillingId = recipientBillingIdOrError.object; + + const recipientShippingIdOrError = ensureParticipantAddressIdIsValid( + customerDTO?.recipient?.shipping_address_id, + ); + if (recipientShippingIdOrError.isFailure) { + return this.handleValidationFailure( + recipientShippingIdOrError.error, + "Recipient shipping address ID not valid", + ); + } + const recipientShippingId = recipientShippingIdOrError.object; + + const recipientContact = await this.findContact( + recipientId, + recipientBillingId, + recipientShippingId, + ); + + if (!recipientContact) { + return this.handleValidationFailure( + new Error(`Recipient with ID ${recipientId.toString()} does not exist`), + ); + } + + // Crear customer + const customerOrError = await this.tryUpdateCustomerInstance( + customerDTO, + customerIdOrError.object, + //senderId, + //senderBillingId, + //senderShippingId, + recipientContact, + ); + + if (customerOrError.isFailure) { + const { error: domainError } = customerOrError; + let errorCode = ""; + let message = ""; + + switch (domainError.code) { + case Customer.ERROR_CUSTOMER_WITHOUT_NAME: + errorCode = UseCaseError.INVALID_INPUT_DATA; + message = + "El cliente debe ser una compañía o tener nombre y apellidos."; + break; + + default: + errorCode = UseCaseError.UNEXCEPTED_ERROR; + message = ""; + break; + } + + return Result.fail( + UseCaseError.create(errorCode, message, domainError), + ); + } + + return this.saveCustomer(customerOrError.object); + + } + + private async tryUpdateCustomerInstance(customerDTO, customerId, recipient) { + // Create customer + let customer_status = CustomerStatus.create(customerDTO.status).object; + if (customer_status.isEmpty()) { + customer_status = CustomerStatus.createDraft(); + } + + let customer_series = CustomerSeries.create(customerDTO.customer_series).object; + if (customer_series.isEmpty()) { + customer_series = CustomerSeries.create(customerDTO.customer_series).object; + } + + let issue_date = CustomerDate.create(customerDTO.issue_date).object; + if (issue_date.isEmpty()) { + issue_date = CustomerDate.createCurrentDate().object; + } + + let operation_date = CustomerDate.create(customerDTO.operation_date).object; + if (operation_date.isEmpty()) { + operation_date = CustomerDate.createCurrentDate().object; + } + + let customerCurrency = Currency.createFromCode(customerDTO.currency).object; + + if (customerCurrency.isEmpty()) { + customerCurrency = Currency.createDefaultCode().object; + } + + let customerLanguage = Language.createFromCode(customerDTO.language_code).object; + + if (customerLanguage.isEmpty()) { + customerLanguage = Language.createDefaultCode().object; + } + + const items = new Collection( + customerDTO.items?.map( + (item) => + CustomerSimpleItem.create({ + description: Description.create(item.description).object, + quantity: Quantity.create(item.quantity).object, + unitPrice: UnitPrice.create({ + amount: item.unit_price.amount, + currencyCode: item.unit_price.currency, + precision: item.unit_price.precision, + }).object, + }).object + ) + ); + + if (!customer_status.isDraft()) { + throw Error("Error al crear una factura que no es borrador"); + } + + return DraftCustomer.create( + { + customerSeries: customer_series, + issueDate: issue_date, + operationDate: operation_date, + customerCurrency, + language: customerLanguage, + customerNumber: CustomerNumber.create(undefined).object, + //notes: Note.create(customerDTO.notes).object, + + //senderId: UniqueID.create(null).object, + recipient, + + items, + }, + customerId + ); + } + + private async findContact( + contactId: UniqueID, + billingAddressId: UniqueID, + shippingAddressId: UniqueID + ) { + const contactRepoBuilder = this.getRepository("Contact"); + + const contact = await contactRepoBuilder().getById2( + contactId, + billingAddressId, + shippingAddressId + ); + + return contact; + } + + private async saveCustomer(customer: DraftCustomer) { + const transaction = this._adapter.startTransaction(); + const customerRepoBuilder = this.getRepository("Customer"); + + try { + await transaction.complete(async (t) => { + const customerRepo = customerRepoBuilder({ transaction: t }); + await customerRepo.save(customer); + }); + + return Result.ok(customer); + } catch (error: unknown) { + const _error = error as Error; + if (customerRepoBuilder().isRepositoryError(_error)) { + return this.handleRepositoryError(error as BaseError, customerRepoBuilder()); + } else { + return this.handleUnexceptedError(error); + } + } + } + + private handleUnexceptedError(error): Result { + return Result.fail( + UseCaseError.create(UseCaseError.UNEXCEPTED_ERROR, error.message, error) + ); + } + + private handleRepositoryError( + error: BaseError, + repository: ICustomerRepository + ): Result { + const { message, details } = repository.handleRepositoryError(error); + return Result.fail( + UseCaseError.create(UseCaseError.REPOSITORY_ERROR, message, details) + ); + } +} + */ diff --git a/modules/customers/src/api/controllers/create-customer/create-customer.ts b/modules/customers/src/api/controllers/create-customer/create-customer.ts new file mode 100644 index 00000000..735c2933 --- /dev/null +++ b/modules/customers/src/api/controllers/create-customer/create-customer.ts @@ -0,0 +1,34 @@ +import { ExpressController, errorMapper } from "@erp/core/api"; +import { CreateCustomerCommandDTO } from "../../../common/dto"; +import { CreateCustomerUseCase } from "../../application"; + +export class CreateCustomerController extends ExpressController { + public constructor(private readonly createCustomer: CreateCustomerUseCase) { + super(); + } + + protected async executeImpl() { + const dto = this.req.body as CreateCustomerCommandDTO; + /* + const user = this.req.user; // asumimos middleware authenticateJWT inyecta user + + if (!user || !user.companyId) { + this.unauthorized(res, "Unauthorized: user or company not found"); + return; + } + + // Inyectar empresa del usuario autenticado (ownership) + dto.customerCompanyId = user.companyId; + */ + + const result = await this.createCustomer.execute(dto); + + if (result.isFailure) { + console.log(result.error); + const apiError = errorMapper.toApiError(result.error); + return this.handleApiError(apiError); + } + + return this.created(result.data); + } +} diff --git a/modules/customers/src/api/controllers/create-customer/index.ts b/modules/customers/src/api/controllers/create-customer/index.ts new file mode 100644 index 00000000..e668b006 --- /dev/null +++ b/modules/customers/src/api/controllers/create-customer/index.ts @@ -0,0 +1,17 @@ +import { SequelizeTransactionManager } from "@erp/core/api"; +import { Sequelize } from "sequelize"; +import { CreateCustomerUseCase, CreateCustomersPresenter } from "../../application/"; +import { CustomerService } from "../../domain"; +import { CustomerMapper } from "../../infrastructure"; +import { CreateCustomerController } from "./create-customer"; + +export const buildCreateCustomersController = (database: Sequelize) => { + const transactionManager = new SequelizeTransactionManager(database); + const customerRepository = new customerRepository(database, new CustomerMapper()); + const customerService = new CustomerService(customerRepository); + const presenter = new CreateCustomersPresenter(); + + const useCase = new CreateCustomerUseCase(customerService, transactionManager, presenter); + + return new CreateCustomerController(useCase); +}; diff --git a/modules/customers/src/api/controllers/delete-customer/delete-invoice.controller.ts b/modules/customers/src/api/controllers/delete-customer/delete-invoice.controller.ts new file mode 100644 index 00000000..388484e6 --- /dev/null +++ b/modules/customers/src/api/controllers/delete-customer/delete-invoice.controller.ts @@ -0,0 +1,30 @@ +import { ExpressController, errorMapper } from "@erp/core/api"; +import { DeleteCustomerUseCase } from "../../application"; + +export class DeleteCustomerController extends ExpressController { + public constructor(private readonly deleteCustomer: DeleteCustomerUseCase) { + super(); + } + + async executeImpl(): Promise { + const { id } = this.req.params; + + /* + const user = this.req.user; // asumimos middleware authenticateJWT inyecta user + + if (!user || !user.companyId) { + this.unauthorized(res, "Unauthorized: user or company not found"); + return; + } + */ + + const result = await this.deleteCustomer.execute({ id }); + + if (result.isFailure) { + const apiError = errorMapper.toApiError(result.error); + return this.handleApiError(apiError); + } + + return this.ok(result.data); + } +} diff --git a/modules/customers/src/api/controllers/delete-customer/index.ts b/modules/customers/src/api/controllers/delete-customer/index.ts new file mode 100644 index 00000000..b411618d --- /dev/null +++ b/modules/customers/src/api/controllers/delete-customer/index.ts @@ -0,0 +1,16 @@ +import { SequelizeTransactionManager } from "@erp/core/api"; +import { Sequelize } from "sequelize"; +import { DeleteCustomerUseCase } from "../../application"; +import { CustomerService } from "../../domain"; +import { CustomerMapper } from "../../infrastructure"; +import { DeleteCustomerController } from "./delete-invoice.controller"; + +export const buildDeleteCustomerController = (database: Sequelize) => { + const transactionManager = new SequelizeTransactionManager(database); + const customerRepository = new customerRepository(database, new CustomerMapper()); + const customerService = new CustomerService(customerRepository); + + const useCase = new DeleteCustomerUseCase(customerService, transactionManager); + + return new DeleteCustomerController(useCase); +}; diff --git a/modules/customers/src/api/controllers/get-customer/get-invoice.controller.ts b/modules/customers/src/api/controllers/get-customer/get-invoice.controller.ts new file mode 100644 index 00000000..2d6c051d --- /dev/null +++ b/modules/customers/src/api/controllers/get-customer/get-invoice.controller.ts @@ -0,0 +1,30 @@ +import { ExpressController, errorMapper } from "@erp/core/api"; +import { GetCustomerUseCase } from "../../application"; + +export class GetCustomerController extends ExpressController { + public constructor(private readonly getCustomer: GetCustomerUseCase) { + super(); + } + + protected async executeImpl() { + const { id } = this.req.params; + + /* + const user = this.req.user; // asumimos middleware authenticateJWT inyecta user + + if (!user || !user.companyId) { + this.unauthorized(res, "Unauthorized: user or company not found"); + return; + } + */ + + const result = await this.getCustomer.execute({ id }); + + if (result.isFailure) { + const apiError = errorMapper.toApiError(result.error); + return this.handleApiError(apiError); + } + + return this.ok(result.data); + } +} diff --git a/modules/customers/src/api/controllers/get-customer/index.ts b/modules/customers/src/api/controllers/get-customer/index.ts new file mode 100644 index 00000000..2d22a0bd --- /dev/null +++ b/modules/customers/src/api/controllers/get-customer/index.ts @@ -0,0 +1,17 @@ +import { SequelizeTransactionManager } from "@erp/core/api"; +import { Sequelize } from "sequelize"; +import { GetCustomerUseCase, getCustomerPresenter } from "../../application"; +import { CustomerService } from "../../domain"; +import { customerMapper } from "../../infrastructure"; +import { GetCustomerController } from "./get-invoice.controller"; + +export const buildGetCustomerController = (database: Sequelize) => { + const transactionManager = new SequelizeTransactionManager(database); + const repository = new CustomerRepository(database, customerMapper); + const customerService = new CustomerService(customerRepository); + const presenter = getCustomerPresenter; + + const useCase = new GetCustomerUseCase(customerService, transactionManager, presenter); + + return new GetCustomerController(useCase); +}; diff --git a/modules/customers/src/api/controllers/index.ts b/modules/customers/src/api/controllers/index.ts new file mode 100644 index 00000000..05492882 --- /dev/null +++ b/modules/customers/src/api/controllers/index.ts @@ -0,0 +1,5 @@ +export * from "./create-customer"; +export * from "./delete-customer"; +export * from "./get-customer"; +export * from "./list-customers"; +///export * from "./update-customer"; diff --git a/modules/customers/src/api/controllers/list-customers/index.ts b/modules/customers/src/api/controllers/list-customers/index.ts new file mode 100644 index 00000000..72f75675 --- /dev/null +++ b/modules/customers/src/api/controllers/list-customers/index.ts @@ -0,0 +1,18 @@ +import { SequelizeTransactionManager } from "@erp/core/api"; +import { Sequelize } from "sequelize"; +import { ListCustomersUseCase } from "../../application"; +import { listCustomersPresenter } from "../../application/list-customers/presenter"; +import { CustomerService } from "../../domain"; +import { CustomerRepository, customerMapper } from "../../infrastructure"; +import { ListCustomersController } from "./list-customers.controller"; + +export const buildListCustomersController = (database: Sequelize) => { + const transactionManager = new SequelizeTransactionManager(database); + const repository = new CustomerRepository(database, customerMapper); + const customerService = new CustomerService(repository); + const presenter = listCustomersPresenter; + + const useCase = new ListCustomersUseCase(customerService, transactionManager, presenter); + + return new ListCustomersController(useCase); +}; diff --git a/modules/customers/src/api/controllers/list-customers/list-customers.controller.ts b/modules/customers/src/api/controllers/list-customers/list-customers.controller.ts new file mode 100644 index 00000000..1168ddb3 --- /dev/null +++ b/modules/customers/src/api/controllers/list-customers/list-customers.controller.ts @@ -0,0 +1,33 @@ +import { ExpressController, errorMapper } from "@erp/core/api"; +import { ListCustomersUseCase } from "../../application"; + +export class ListCustomersController extends ExpressController { + public constructor(private readonly listCustomers: ListCustomersUseCase) { + super(); + } + + protected async executeImpl() { + const criteria = this.criteria; + + /* + const user = this.req.user; // asumimos middleware authenticateJWT inyecta user + + if (!user || !user.companyId) { + this.unauthorized(res, "Unauthorized: user or company not found"); + return; + } + + // Inyectar empresa del usuario autenticado (ownership) + this.criteria.addFilter("companyId", "=", companyId); + */ + + const result = await this.listCustomers.execute(criteria); + + if (result.isFailure) { + const apiError = errorMapper.toApiError(result.error); + return this.handleApiError(apiError); + } + + return this.ok(result.data); + } +} diff --git a/modules/customers/src/api/controllers/update-customer/index.ts.bak b/modules/customers/src/api/controllers/update-customer/index.ts.bak new file mode 100644 index 00000000..e3e00cd2 --- /dev/null +++ b/modules/customers/src/api/controllers/update-customer/index.ts.bak @@ -0,0 +1,57 @@ +import { IInvoicingContext } from "#/server/intrastructure"; +import { CustomerRepository } from "#/server/intrastructure/Customer.repository"; + +export const updateCustomerController = (context: IInvoicingContext) => { + const adapter = context.adapter; + const repoManager = context.repositoryManager; + + repoManager.registerRepository("Customer", (params = { transaction: null }) => { + const { transaction } = params; + + return new CustomerRepository({ + transaction, + adapter, + mapper: createCustomerMapper(context), + }); + }); + + repoManager.registerRepository("Participant", (params = { transaction: null }) => { + const { transaction } = params; + + return new CustomerParticipantRepository({ + transaction, + adapter, + mapper: createCustomerParticipantMapper(context), + }); + }); + + repoManager.registerRepository("ParticipantAddress", (params = { transaction: null }) => { + const { transaction } = params; + + return new CustomerParticipantAddressRepository({ + transaction, + adapter, + mapper: createCustomerParticipantAddressMapper(context), + }); + }); + + repoManager.registerRepository("Contact", (params = { transaction: null }) => { + const { transaction } = params; + + return new ContactRepository({ + transaction, + adapter, + mapper: createContactMapper(context), + }); + }); + + const updateCustomerUseCase = new UpdateCustomerUseCase(context); + + return new UpdateCustomerController( + { + useCase: updateCustomerUseCase, + presenter: updateCustomerPresenter, + }, + context + ); +}; diff --git a/modules/customers/src/api/controllers/update-customer/presenter/InvoiceItem.presenter.ts.bak b/modules/customers/src/api/controllers/update-customer/presenter/InvoiceItem.presenter.ts.bak new file mode 100644 index 00000000..5565287c --- /dev/null +++ b/modules/customers/src/api/controllers/update-customer/presenter/InvoiceItem.presenter.ts.bak @@ -0,0 +1,19 @@ +import { CustomerItem } from "@/contexts/invoicing/domain/CustomerItems"; +import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext"; +import { ICollection, IMoney_Response_DTO } from "@shared/contexts"; + +export const customerItemPresenter = ( + items: ICollection, + context: IInvoicingContext +) => + items.totalCount > 0 + ? items.items.map((item: CustomerItem) => ({ + description: item.description.toString(), + quantity: item.quantity.toString(), + unit_measure: "", + unit_price: item.unitPrice.toPrimitive() as IMoney_Response_DTO, + subtotal: item.calculateSubtotal().toPrimitive() as IMoney_Response_DTO, + tax_amount: item.calculateTaxAmount().toPrimitive() as IMoney_Response_DTO, + total: item.calculateTotal().toPrimitive() as IMoney_Response_DTO, + })) + : []; diff --git a/modules/customers/src/api/controllers/update-customer/presenter/InvoiceParticipant.presenter.ts.bak b/modules/customers/src/api/controllers/update-customer/presenter/InvoiceParticipant.presenter.ts.bak new file mode 100644 index 00000000..bf29da73 --- /dev/null +++ b/modules/customers/src/api/controllers/update-customer/presenter/InvoiceParticipant.presenter.ts.bak @@ -0,0 +1,26 @@ +import { ICustomerParticipant } from "@/contexts/invoicing/domain"; +import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext"; +import { IUpdateCustomer_Participant_Response_DTO } from "@shared/contexts"; +import { CustomerParticipantAddressPresenter } from "./CustomerParticipantAddress.presenter"; + +export const CustomerParticipantPresenter = ( + participant: ICustomerParticipant, + context: IInvoicingContext, +): IUpdateCustomer_Participant_Response_DTO | undefined => { + return { + id: participant.id.toString(), + tin: participant.tin.toString(), + first_name: participant.firstName.toString(), + last_name: participant.lastName.toString(), + company_name: participant.companyName.toString(), + + billing_address: CustomerParticipantAddressPresenter( + participant.billingAddress!, + context, + ), + shipping_address: CustomerParticipantAddressPresenter( + participant.shippingAddress!, + context, + ), + }; +}; diff --git a/modules/customers/src/api/controllers/update-customer/presenter/InvoiceParticipantAddress.presenter.ts.bak b/modules/customers/src/api/controllers/update-customer/presenter/InvoiceParticipantAddress.presenter.ts.bak new file mode 100644 index 00000000..82b4aef7 --- /dev/null +++ b/modules/customers/src/api/controllers/update-customer/presenter/InvoiceParticipantAddress.presenter.ts.bak @@ -0,0 +1,19 @@ +import { CustomerParticipantAddress } from "@/contexts/invoicing/domain"; +import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext"; +import { IUpdateCustomer_AddressParticipant_Response_DTO } from "@shared/contexts"; + +export const CustomerParticipantAddressPresenter = ( + address: CustomerParticipantAddress, + context: IInvoicingContext, +): IUpdateCustomer_AddressParticipant_Response_DTO => { + return { + id: address.id.toString(), + street: address.street.toString(), + city: address.city.toString(), + postal_code: address.postalCode.toString(), + province: address.province.toString(), + country: address.country.toString(), + email: address.email.toString(), + phone: address.phone.toString(), + }; +}; diff --git a/modules/customers/src/api/controllers/update-customer/presenter/UpdateInvoice.presenter.ts.bak b/modules/customers/src/api/controllers/update-customer/presenter/UpdateInvoice.presenter.ts.bak new file mode 100644 index 00000000..a7acad78 --- /dev/null +++ b/modules/customers/src/api/controllers/update-customer/presenter/UpdateInvoice.presenter.ts.bak @@ -0,0 +1,33 @@ +import { Customer } from "@/contexts/invoicing/domain"; +import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext"; +import { IUpdateCustomer_Response_DTO } from "@shared/contexts"; +import { customerItemPresenter } from "./CustomerItem.presenter"; +import { CustomerParticipantPresenter } from "./CustomerParticipant.presenter"; + +export interface IUpdateCustomerPresenter { + map: (customer: Customer, context: IInvoicingContext) => IUpdateCustomer_Response_DTO; +} + +export const updateCustomerPresenter: IUpdateCustomerPresenter = { + map: (customer: Customer, context: IInvoicingContext): IUpdateCustomer_Response_DTO => { + return { + id: customer.id.toString(), + + customer_status: customer.status.toString(), + customer_number: customer.customerNumber.toString(), + customer_series: customer.customerSeries.toString(), + issue_date: customer.issueDate.toISO8601(), + operation_date: customer.operationDate.toISO8601(), + language_code: customer.language.toString(), + currency: customer.currency.toString(), + subtotal: customer.calculateSubtotal().toPrimitive(), + total: customer.calculateTotal().toPrimitive(), + + //sender: {}, //await CustomerParticipantPresenter(customer.senderId, context), + + recipient: CustomerParticipantPresenter(customer.recipient, context), + + items: customerItemPresenter(customer.items, context), + }; + }, +}; diff --git a/modules/customers/src/api/controllers/update-customer/presenter/index.ts.bak b/modules/customers/src/api/controllers/update-customer/presenter/index.ts.bak new file mode 100644 index 00000000..0ed47025 --- /dev/null +++ b/modules/customers/src/api/controllers/update-customer/presenter/index.ts.bak @@ -0,0 +1 @@ +export * from "./UpdateCustomer.presenter"; diff --git a/modules/customers/src/api/controllers/update-customer/update-invoice.controller.ts.bak b/modules/customers/src/api/controllers/update-customer/update-invoice.controller.ts.bak new file mode 100644 index 00000000..8f1a1552 --- /dev/null +++ b/modules/customers/src/api/controllers/update-customer/update-invoice.controller.ts.bak @@ -0,0 +1,72 @@ +import { IInvoicingContext } from "#/server/intrastructure"; +import { ExpressController } from "@rdx/core"; +import { IUpdateCustomerPresenter } from "./presenter"; + +export class UpdateCustomerController extends ExpressController { + private useCase: UpdateCustomerUseCase2; + private presenter: IUpdateCustomerPresenter; + private context: IInvoicingContext; + + constructor( + props: { + useCase: UpdateCustomerUseCase; + presenter: IUpdateCustomerPresenter; + }, + context: IInvoicingContext + ) { + super(); + + const { useCase, presenter } = props; + this.useCase = useCase; + this.presenter = presenter; + this.context = context; + } + + async executeImpl(): Promise { + const { customerId } = this.req.params; + const request: IUpdateCustomer_DTO = this.req.body; + + if (RuleValidator.validate(RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, customerId).isFailure) { + return this.invalidInputError("Customer Id param is required!"); + } + + const idOrError = UniqueID.create(customerId); + if (idOrError.isFailure) { + return this.invalidInputError("Invalid customer Id param!"); + } + + try { + const result = await this.useCase.execute({ + id: idOrError.object, + data: request, + }); + + if (result.isFailure) { + const { error } = result; + + switch (error.code) { + case UseCaseError.NOT_FOUND_ERROR: + return this.notFoundError("Customer not found", error); + + case UseCaseError.INVALID_INPUT_DATA: + return this.invalidInputError(error.message); + + case UseCaseError.UNEXCEPTED_ERROR: + return this.internalServerError(result.error.message, result.error); + + case UseCaseError.REPOSITORY_ERROR: + return this.conflictError(result.error, result.error.details); + + default: + return this.clientError(result.error.message); + } + } + + const customer = result.object; + + return this.ok(this.presenter.map(customer, this.context)); + } catch (e: unknown) { + return this.fail(e as IServerError); + } + } +} diff --git a/modules/customers/src/api/domain/aggregates/customer.ts b/modules/customers/src/api/domain/aggregates/customer.ts new file mode 100644 index 00000000..5a749375 --- /dev/null +++ b/modules/customers/src/api/domain/aggregates/customer.ts @@ -0,0 +1,130 @@ +import { + AggregateRoot, + EmailAddress, + PhoneNumber, + PostalAddress, + TINNumber, + UniqueID, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +export interface CustomerProps { + reference: string; + isFreelancer: boolean; + name: string; + tin: TINNumber; + address: PostalAddress; + email: EmailAddress; + phone: PhoneNumber; + legalRecord: string; + defaultTax: number; + status: string; + langCode: string; + currencyCode: string; + + tradeName: Maybe; + website: Maybe; + fax: Maybe; +} + +export interface ICustomer { + id: UniqueID; + reference: string; + name: string; + tin: TINNumber; + address: PostalAddress; + email: EmailAddress; + phone: PhoneNumber; + legalRecord: string; + defaultTax: number; + langCode: string; + currencyCode: string; + + tradeName: Maybe; + fax: Maybe; + website: Maybe; + + isCustomer: boolean; + isFreelancer: boolean; + isActive: boolean; +} + +export class Customer extends AggregateRoot implements ICustomer { + static create(props: CustomerProps, id?: UniqueID): Result { + const contact = new Customer(props, id); + + // Reglas de negocio / validaciones + // ... + // ... + + // 🔹 Disparar evento de dominio "CustomerAuthenticatedEvent" + //const { contact } = props; + //user.addDomainEvent(new CustomerAuthenticatedEvent(id, contact.toString())); + + return Result.ok(contact); + } + + get reference() { + return this.props.reference; + } + + get name() { + return this.props.name; + } + + get tradeName() { + return this.props.tradeName; + } + + get tin(): TINNumber { + return this.props.tin; + } + + get address(): PostalAddress { + return this.props.address; + } + + get email(): EmailAddress { + return this.props.email; + } + + get phone(): PhoneNumber { + return this.props.phone; + } + + get fax(): Maybe { + return this.props.fax; + } + + get website() { + return this.props.website; + } + + get legalRecord() { + return this.props.legalRecord; + } + + get defaultTax() { + return this.props.defaultTax; + } + + get langCode() { + return this.props.langCode; + } + + get currencyCode() { + return this.props.currencyCode; + } + + get isCustomer(): boolean { + return !this.props.isFreelancer; + } + + get isFreelancer(): boolean { + return this.props.isFreelancer; + } + + get isActive(): boolean { + return this.props.status === "active"; + } +} diff --git a/modules/customers/src/api/domain/aggregates/index.ts b/modules/customers/src/api/domain/aggregates/index.ts new file mode 100644 index 00000000..2b295031 --- /dev/null +++ b/modules/customers/src/api/domain/aggregates/index.ts @@ -0,0 +1 @@ +export * from "./customer"; diff --git a/modules/customers/src/api/domain/index.ts b/modules/customers/src/api/domain/index.ts new file mode 100644 index 00000000..5dcd597d --- /dev/null +++ b/modules/customers/src/api/domain/index.ts @@ -0,0 +1,4 @@ +export * from "./aggregates"; +export * from "./repositories"; +export * from "./services"; +export * from "./value-objects"; diff --git a/modules/customers/src/api/domain/repositories/customer-repository.interface.ts b/modules/customers/src/api/domain/repositories/customer-repository.interface.ts new file mode 100644 index 00000000..c281b27b --- /dev/null +++ b/modules/customers/src/api/domain/repositories/customer-repository.interface.ts @@ -0,0 +1,50 @@ +import { Criteria } from "@repo/rdx-criteria/server"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Result } from "@repo/rdx-utils"; +import { Customer } from "../aggregates"; + +export interface ICustomerRepository { + existsById(id: UniqueID, transaction?: any): Promise>; + + /** + * + * Persiste una nueva factura o actualiza una existente. + * + * @param customer - El agregado a guardar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + save(customer: Customer, transaction: any): Promise>; + + /** + * + * Busca una factura por su identificador único. + * @param id - UUID de la factura. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + findById(id: UniqueID, transaction: any): Promise>; + + /** + * + * Consulta facturas usando un objeto Criteria (filtros, orden, paginación). + * @param criteria - Criterios de búsqueda. + * @param transaction - Transacción activa para la operación. + * @returns Result + * + * @see Criteria + */ + findByCriteria( + criteria: Criteria, + transaction: any + ): Promise, Error>>; + + /** + * + * Elimina o marca como eliminada una factura. + * @param id - UUID de la factura a eliminar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + deleteById(id: UniqueID, transaction: any): Promise>; +} diff --git a/modules/customers/src/api/domain/repositories/index.ts b/modules/customers/src/api/domain/repositories/index.ts new file mode 100644 index 00000000..2ae98271 --- /dev/null +++ b/modules/customers/src/api/domain/repositories/index.ts @@ -0,0 +1 @@ +export * from "./customer-repository.interface"; diff --git a/modules/customers/src/api/domain/services/customer-service.interface.ts b/modules/customers/src/api/domain/services/customer-service.interface.ts new file mode 100644 index 00000000..be4afbff --- /dev/null +++ b/modules/customers/src/api/domain/services/customer-service.interface.ts @@ -0,0 +1,33 @@ +import { Criteria } from "@repo/rdx-criteria/server"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Result } from "@repo/rdx-utils"; +import { Customer, CustomerProps } from "../aggregates"; + +export interface ICustomerService { + build(props: CustomerProps, id?: UniqueID): Result; + + save(invoice: Customer, transaction: any): Promise>; + + existsById(id: UniqueID, transaction?: any): Promise>; + + findByCriteria( + criteria: Criteria, + transaction?: any + ): Promise, Error>>; + + getById(id: UniqueID, transaction?: any): Promise>; + + updateById( + id: UniqueID, + data: Partial, + transaction?: any + ): Promise>; + + createCustomer( + id: UniqueID, + data: CustomerProps, + transaction?: any + ): Promise>; + + deleteById(id: UniqueID, transaction?: any): Promise>; +} diff --git a/modules/customers/src/api/domain/services/customer.service.ts b/modules/customers/src/api/domain/services/customer.service.ts new file mode 100644 index 00000000..6fd51136 --- /dev/null +++ b/modules/customers/src/api/domain/services/customer.service.ts @@ -0,0 +1,149 @@ +import { Criteria } from "@repo/rdx-criteria/server"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Result } from "@repo/rdx-utils"; +import { Transaction } from "sequelize"; +import { Customer, CustomerProps } from "../aggregates"; +import { ICustomerRepository } from "../repositories"; +import { ICustomerService } from "./customer-service.interface"; + +export class CustomerService implements ICustomerService { + constructor(private readonly repository: ICustomerRepository) {} + + /** + * Construye un nuevo agregado Customer a partir de props validadas. + * + * @param props - Las propiedades ya validadas para crear la factura. + * @param id - Identificador UUID de la factura (opcional). + * @returns Result - El agregado construido o un error si falla la creación. + */ + build(props: CustomerProps, id?: UniqueID): Result { + return Customer.create(props, id); + } + + /** + * Guarda una instancia de Customer en persistencia. + * + * @param invoice - El agregado a guardar. + * @param transaction - Transacción activa para la operación. + * @returns Result - El agregado guardado o un error si falla la operación. + */ + async save(invoice: Customer, transaction: any): Promise> { + const saved = await this.repository.save(invoice, transaction); + return saved.isSuccess ? Result.ok(invoice) : Result.fail(saved.error); + } + + /** + * + * Comprueba si existe o no en persistencia una factura con el ID proporcionado + * + * @param id - Identificador UUID de la factura. + * @param transaction - Transacción activa para la operación. + * @returns Result - Existe la factura o no. + */ + + async existsById(id: UniqueID, transaction?: any): Promise> { + return this.repository.existsById(id, transaction); + } + + /** + * Obtiene una colección de facturas que cumplen con los filtros definidos en un objeto Criteria. + * + * @param criteria - Objeto con condiciones de filtro, paginación y orden. + * @param transaction - Transacción activa para la operación. + * @returns Result, Error> - Colección de facturas o error. + */ + async findByCriteria( + criteria: Criteria, + transaction?: Transaction + ): Promise, Error>> { + const customersOrError = await this.repository.findByCriteria(criteria, transaction); + if (customersOrError.isFailure) { + console.error(customersOrError.error); + return Result.fail(customersOrError.error); + } + + // Solo devolver usuarios activos + //const allCustomers = customersOrError.data.filter((customer) => customer.isActive); + //return Result.ok(new Collection(allCustomers)); + + return customersOrError; + } + + /** + * Recupera una factura por su identificador único. + * + * @param id - Identificador UUID de la factura. + * @param transaction - Transacción activa para la operación. + * @returns Result - Factura encontrada o error. + */ + async getById(id: UniqueID, transaction?: Transaction): Promise> { + return await this.repository.findById(id, transaction); + } + + /** + * Actualiza parcialmente una factura existente con nuevos datos. + * + * @param id - Identificador de la factura a actualizar. + * @param changes - Subconjunto de props válidas para aplicar. + * @param transaction - Transacción activa para la operación. + * @returns Result - Factura actualizada o error. + */ + async updateById( + customerId: UniqueID, + changes: Partial, + transaction?: Transaction + ): Promise> { + // Verificar si la factura existe + const customerOrError = await this.repository.findById(customerId, transaction); + if (customerOrError.isFailure) { + return Result.fail(new Error("Customer not found")); + } + + return Result.fail(new Error("No implementado")); + + /*const updatedCustomerOrError = Customer.update(customerOrError.data, data); + if (updatedCustomerOrError.isFailure) { + return Result.fail( + new Error(`Error updating customer: ${updatedCustomerOrError.error.message}`) + ); + } + + const updateCustomer = updatedCustomerOrError.data; + + await this.repo.update(updateCustomer, transaction); + return Result.ok(updateCustomer);*/ + } + + async createCustomer( + customerId: UniqueID, + data: CustomerProps, + transaction?: Transaction + ): Promise> { + // Verificar si la factura existe + const customerOrError = await this.repository.findById(customerId, transaction); + if (customerOrError.isSuccess) { + return Result.fail(new Error("Customer exists")); + } + + const newCustomerOrError = Customer.create(data, customerId); + if (newCustomerOrError.isFailure) { + return Result.fail(new Error(`Error creating customer: ${newCustomerOrError.error.message}`)); + } + + const newCustomer = newCustomerOrError.data; + + await this.repository.create(newCustomer, transaction); + return Result.ok(newCustomer); + } + + /** + * Elimina (o marca como eliminada) una factura según su ID. + * + * @param id - Identificador UUID de la factura. + * @param transaction - Transacción activa para la operación. + * @returns Result - Resultado de la operación. + */ + async deleteById(id: UniqueID, transaction?: Transaction): Promise> { + return this.repository.deleteById(id, transaction); + } +} diff --git a/modules/customers/src/api/domain/services/index.ts b/modules/customers/src/api/domain/services/index.ts new file mode 100644 index 00000000..fd8abcbb --- /dev/null +++ b/modules/customers/src/api/domain/services/index.ts @@ -0,0 +1,2 @@ +export * from "./customer-service.interface"; +export * from "./customer.service"; diff --git a/modules/customers/src/api/domain/value-objects/customer-address-type.ts b/modules/customers/src/api/domain/value-objects/customer-address-type.ts new file mode 100644 index 00000000..7cf89257 --- /dev/null +++ b/modules/customers/src/api/domain/value-objects/customer-address-type.ts @@ -0,0 +1,38 @@ +import { ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +interface ICustomerAddressTypeProps { + value: string; +} + +export enum INVOICE_ADDRESS_TYPE { + SHIPPING = "shipping", + BILLING = "billing", +} + +export class CustomerAddressType extends ValueObject { + private static readonly ALLOWED_TYPES = ["shipping", "billing"]; + + static create(value: string): Result { + if (!this.ALLOWED_TYPES.includes(value)) { + return Result.fail( + new Error( + `Invalid address type: ${value}. Allowed types are: ${this.ALLOWED_TYPES.join(", ")}` + ) + ); + } + return Result.ok(new CustomerAddressType({ value })); + } + + getValue(): string { + return this.props.value; + } + + toString(): string { + return this.getValue(); + } + + toPrimitive(): string { + return this.getValue(); + } +} diff --git a/modules/customers/src/api/domain/value-objects/customer-number.ts b/modules/customers/src/api/domain/value-objects/customer-number.ts new file mode 100644 index 00000000..0132e50d --- /dev/null +++ b/modules/customers/src/api/domain/value-objects/customer-number.ts @@ -0,0 +1,48 @@ +import { DomainValidationError } from "@erp/core/api"; +import { ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import * as z from "zod/v4"; + +interface ICustomerNumberProps { + value: string; +} + +export class CustomerNumber extends ValueObject { + private static readonly MAX_LENGTH = 255; + private static readonly FIELD = "invoiceNumber"; + private static readonly ERROR_CODE = "INVALID_INVOICE_NUMBER"; + + protected static validate(value: string) { + const schema = z + .string() + .trim() + .max(CustomerNumber.MAX_LENGTH, { + message: `String must be at most ${CustomerNumber.MAX_LENGTH} characters long`, + }); + return schema.safeParse(value); + } + + static create(value: string) { + const result = CustomerNumber.validate(value); + + if (!result.success) { + const detail = result.error.message; + return Result.fail( + new DomainValidationError(CustomerNumber.ERROR_CODE, CustomerNumber.FIELD, detail) + ); + } + return Result.ok(new CustomerNumber({ value })); + } + + getValue(): string { + return this.props.value; + } + + toString(): string { + return this.getValue(); + } + + toPrimitive() { + return this.getValue(); + } +} diff --git a/modules/customers/src/api/domain/value-objects/customer-serie.ts b/modules/customers/src/api/domain/value-objects/customer-serie.ts new file mode 100644 index 00000000..f2c0bf8e --- /dev/null +++ b/modules/customers/src/api/domain/value-objects/customer-serie.ts @@ -0,0 +1,56 @@ +import { DomainValidationError } from "@erp/core/api"; +import { ValueObject } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import * as z from "zod/v4"; + +interface ICustomerSerieProps { + value: string; +} + +export class CustomerSerie extends ValueObject { + private static readonly MAX_LENGTH = 255; + private static readonly FIELD = "invoiceSeries"; + private static readonly ERROR_CODE = "INVALID_INVOICE_SERIE"; + + protected static validate(value: string) { + const schema = z + .string() + .trim() + .max(CustomerSerie.MAX_LENGTH, { + message: `String must be at most ${CustomerSerie.MAX_LENGTH} characters long`, + }); + return schema.safeParse(value); + } + + static create(value: string) { + const result = CustomerSerie.validate(value); + + if (!result.success) { + const detail = result.error.message; + return Result.fail( + new DomainValidationError(CustomerSerie.ERROR_CODE, CustomerSerie.FIELD, detail) + ); + } + return Result.ok(new CustomerSerie({ value })); + } + + static createNullable(value?: string): Result, Error> { + if (!value || value.trim() === "") { + return Result.ok(Maybe.none()); + } + + return CustomerSerie.create(value).map((value) => Maybe.some(value)); + } + + getValue(): string { + return this.props.value; + } + + toString(): string { + return this.getValue(); + } + + toPrimitive() { + return this.getValue(); + } +} diff --git a/modules/customers/src/api/domain/value-objects/customer-status.ts b/modules/customers/src/api/domain/value-objects/customer-status.ts new file mode 100644 index 00000000..ca424353 --- /dev/null +++ b/modules/customers/src/api/domain/value-objects/customer-status.ts @@ -0,0 +1,94 @@ +import { DomainValidationError } from "@erp/core/api"; +import { ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +interface ICustomerStatusProps { + value: string; +} + +export enum INVOICE_STATUS { + DRAFT = "draft", + EMITTED = "emitted", + SENT = "sent", + RECEIVED = "received", + REJECTED = "rejected", +} +export class CustomerStatus extends ValueObject { + private static readonly ALLOWED_STATUSES = ["draft", "emitted", "sent", "received", "rejected"]; + private static readonly FIELD = "invoiceStatus"; + private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS"; + + private static readonly TRANSITIONS: Record = { + draft: [INVOICE_STATUS.EMITTED], + emitted: [INVOICE_STATUS.SENT, INVOICE_STATUS.REJECTED, INVOICE_STATUS.DRAFT], + sent: [INVOICE_STATUS.RECEIVED, INVOICE_STATUS.REJECTED], + received: [], + rejected: [], + }; + + static create(value: string): Result { + if (!CustomerStatus.ALLOWED_STATUSES.includes(value)) { + const detail = `Estado de la factura no válido: ${value}`; + return Result.fail( + new DomainValidationError(CustomerStatus.ERROR_CODE, CustomerStatus.FIELD, detail) + ); + } + + return Result.ok( + value === "rejected" + ? CustomerStatus.createRejected() + : value === "sent" + ? CustomerStatus.createSent() + : value === "emitted" + ? CustomerStatus.createSent() + : value === "" + ? CustomerStatus.createReceived() + : CustomerStatus.createDraft() + ); + } + + public static createDraft(): CustomerStatus { + return new CustomerStatus({ value: INVOICE_STATUS.DRAFT }); + } + + public static createEmitted(): CustomerStatus { + return new CustomerStatus({ value: INVOICE_STATUS.EMITTED }); + } + + public static createSent(): CustomerStatus { + return new CustomerStatus({ value: INVOICE_STATUS.SENT }); + } + + public static createReceived(): CustomerStatus { + return new CustomerStatus({ value: INVOICE_STATUS.RECEIVED }); + } + + public static createRejected(): CustomerStatus { + return new CustomerStatus({ value: INVOICE_STATUS.REJECTED }); + } + + getValue(): string { + return this.props.value; + } + + toPrimitive() { + return this.getValue(); + } + + canTransitionTo(nextStatus: string): boolean { + return CustomerStatus.TRANSITIONS[this.props.value].includes(nextStatus); + } + + transitionTo(nextStatus: string): Result { + if (!this.canTransitionTo(nextStatus)) { + return Result.fail( + new Error(`Transición no permitida de ${this.props.value} a ${nextStatus}`) + ); + } + return CustomerStatus.create(nextStatus); + } + + toString(): string { + return this.getValue(); + } +} diff --git a/modules/customers/src/api/domain/value-objects/index.ts b/modules/customers/src/api/domain/value-objects/index.ts new file mode 100644 index 00000000..76ac2983 --- /dev/null +++ b/modules/customers/src/api/domain/value-objects/index.ts @@ -0,0 +1,4 @@ +export * from "./customer-address-type"; +export * from "./customer-number"; +export * from "./customer-serie"; +export * from "./customer-status"; diff --git a/modules/customers/src/api/index.ts b/modules/customers/src/api/index.ts index 65408e53..5a927c2e 100644 --- a/modules/customers/src/api/index.ts +++ b/modules/customers/src/api/index.ts @@ -1,5 +1,6 @@ import { IModuleServer, ModuleParams } from "@erp/core/api"; -//import { customerInvoicesRouter, models } from "./infrastructure"; +import { customersRouter, models } from "./infrastructure"; +//import { customersRouter, models } from "./infrastructure"; export const customersAPIModule: IModuleServer = { name: "customers", @@ -9,7 +10,7 @@ export const customersAPIModule: IModuleServer = { init(params: ModuleParams) { // const contacts = getService("contacts"); const { logger } = params; - //customerInvoicesRouter(params); + customersRouter(params); logger.info("🚀 Customers module initialized", { label: "customers" }); }, registerDependencies(params) { @@ -18,7 +19,7 @@ export const customersAPIModule: IModuleServer = { label: "customers", }); return { - //models, + models, services: { /*...*/ }, diff --git a/modules/customers/src/api/infrastructure/express/customers.routes.ts b/modules/customers/src/api/infrastructure/express/customers.routes.ts new file mode 100644 index 00000000..644030c3 --- /dev/null +++ b/modules/customers/src/api/infrastructure/express/customers.routes.ts @@ -0,0 +1,78 @@ +import { ILogger, ModuleParams, validateRequest } from "@erp/core/api"; +import { Application, NextFunction, Request, Response, Router } from "express"; +import { Sequelize } from "sequelize"; +import { + CreateCustomerCommandSchema, + CustomerListCriteriaSchema, + DeleteCustomerByIdQuerySchema, + GetCustomerByIdQuerySchema, +} from "../../../common/dto"; +import { + buildCreateCustomersController, + buildDeleteCustomerController, + buildGetCustomerController, + buildListCustomersController, +} from "../../controllers"; + +export const customersRouter = (params: ModuleParams) => { + const { app, database, baseRoutePath, logger } = params as { + app: Application; + database: Sequelize; + baseRoutePath: string; + logger: ILogger; + }; + + const routes: Router = Router({ mergeParams: true }); + + routes.get( + "/", + //checkTabContext, + //checkUser, + validateRequest(CustomerListCriteriaSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + buildListCustomersController(database).execute(req, res, next); + } + ); + + routes.get( + "/:id", + //checkTabContext, + //checkUser, + validateRequest(GetCustomerByIdQuerySchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + buildGetCustomerController(database).execute(req, res, next); + } + ); + + routes.post( + "/", + //checkTabContext, + //checkUser, + validateRequest(CreateCustomerCommandSchema), + (req: Request, res: Response, next: NextFunction) => { + buildCreateCustomersController(database).execute(req, res, next); + } + ); + + /*routes.put( + "/:customerId", + validateAndParseBody(IUpdateCustomerRequestSchema), + checkTabContext, + //checkUser, + (req: Request, res: Response, next: NextFunction) => { + buildUpdateCustomerController().execute(req, res, next); + } + );*/ + + routes.delete( + "/:id", + //checkTabContext, + //checkUser, + validateRequest(DeleteCustomerByIdQuerySchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + buildDeleteCustomerController(database).execute(req, res, next); + } + ); + + app.use(`${baseRoutePath}/customers`, routes); +}; diff --git a/modules/customers/src/api/infrastructure/express/index.ts b/modules/customers/src/api/infrastructure/express/index.ts new file mode 100644 index 00000000..8280b4b5 --- /dev/null +++ b/modules/customers/src/api/infrastructure/express/index.ts @@ -0,0 +1 @@ +export * from "./customers.routes"; diff --git a/modules/customers/src/api/infrastructure/index.ts b/modules/customers/src/api/infrastructure/index.ts new file mode 100644 index 00000000..f35c8878 --- /dev/null +++ b/modules/customers/src/api/infrastructure/index.ts @@ -0,0 +1,3 @@ +export * from "./express"; +export * from "./mappers"; +export * from "./sequelize"; diff --git a/modules/customers/src/api/infrastructure/mappers/customer-item.mapper.ts b/modules/customers/src/api/infrastructure/mappers/customer-item.mapper.ts new file mode 100644 index 00000000..b50b15d8 --- /dev/null +++ b/modules/customers/src/api/infrastructure/mappers/customer-item.mapper.ts @@ -0,0 +1,128 @@ +import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { InferCreationAttributes } from "sequelize"; +import { + Customer, + CustomerItem, + CustomerItemDescription, + CustomerItemDiscount, + CustomerItemQuantity, + CustomerItemUnitPrice, +} from "../../domain"; +import { CustomerItemCreationAttributes, CustomerItemModel, CustomerModel } from "../sequelize"; + +export interface ICustomerItemMapper + extends ISequelizeMapper {} + +export class CustomerItemMapper + extends SequelizeMapper + implements ICustomerItemMapper +{ + public mapToDomain( + source: CustomerItemModel, + params?: MapperParamsType + ): Result { + const { sourceParent } = params as { sourceParent: CustomerModel }; + + // Validación y creación de ID único + const idOrError = UniqueID.create(source.item_id); + if (idOrError.isFailure) { + return Result.fail(idOrError.error); + } + + // Validación y creación de descripción + const descriptionOrError = CustomerItemDescription.create(source.description || ""); + if (descriptionOrError.isFailure) { + return Result.fail(descriptionOrError.error); + } + + // Validación y creación de cantidad + const quantityOrError = CustomerItemQuantity.create({ + amount: source.quantity_amount, + scale: source.quantity_scale, + }); + if (quantityOrError.isFailure) { + return Result.fail(quantityOrError.error); + } + + // Validación y creación de precio unitario + const unitPriceOrError = CustomerItemUnitPrice.create({ + amount: source.unit_price_amount, + scale: source.unit_price_scale, + currency_code: sourceParent.invoice_currency, + }); + if (unitPriceOrError.isFailure) { + return Result.fail(unitPriceOrError.error); + } + + // Validación y creación de descuento + const discountOrError = CustomerItemDiscount.create({ + amount: source.discount_amount || 0, + scale: source.discount_scale || 0, + }); + if (discountOrError.isFailure) { + return Result.fail(discountOrError.error); + } + + // Combinación de resultados + const result = Result.combine([ + idOrError, + descriptionOrError, + quantityOrError, + unitPriceOrError, + discountOrError, + ]); + + if (result.isFailure) { + return Result.fail(result.error); + } + + // Creación del objeto de dominio + return CustomerItem.create( + { + description: descriptionOrError.data, + quantity: quantityOrError.data, + unitPrice: unitPriceOrError.data, + discount: discountOrError.data, + }, + idOrError.data + ); + } + + public mapToPersistence( + source: CustomerItem, + params?: MapperParamsType + ): InferCreationAttributes { + const { index, sourceParent } = params as { + index: number; + sourceParent: Customer; + }; + + const lineData = { + parent_id: undefined, + invoice_id: sourceParent.id.toPrimitive(), + item_type: "simple", + position: index, + + item_id: source.id.toPrimitive(), + description: source.description.toPrimitive(), + + quantity_amount: source.quantity.toPrimitive().amount, + quantity_scale: source.quantity.toPrimitive().scale, + + unit_price_amount: source.unitPrice.toPrimitive().amount, + unit_price_scale: source.unitPrice.toPrimitive().scale, + + subtotal_amount: source.subtotalPrice.toPrimitive().amount, + subtotal_scale: source.subtotalPrice.toPrimitive().scale, + + discount_amount: source.discount.toPrimitive().amount, + discount_scale: source.discount.toPrimitive().scale, + + total_amount: source.totalPrice.toPrimitive().amount, + total_scale: source.totalPrice.toPrimitive().scale, + }; + return lineData; + } +} diff --git a/modules/customers/src/api/infrastructure/mappers/customer.mapper.ts b/modules/customers/src/api/infrastructure/mappers/customer.mapper.ts new file mode 100644 index 00000000..335e541c --- /dev/null +++ b/modules/customers/src/api/infrastructure/mappers/customer.mapper.ts @@ -0,0 +1,97 @@ +import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api"; +import { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { Customer, CustomerNumber, CustomerSerie, CustomerStatus } from "../../domain"; +import { CustomerCreationAttributes, CustomerModel } from "../sequelize"; +import { CustomerItemMapper } from "./customer-item.mapper"; + +export interface ICustomerMapper + extends ISequelizeMapper {} + +export class CustomerMapper + extends SequelizeMapper + implements ICustomerMapper +{ + private customerItemMapper: CustomerItemMapper; + + constructor() { + super(); + this.customerItemMapper = new CustomerItemMapper(); // Instanciar el mapper de items + } + + public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result { + const idOrError = UniqueID.create(source.id); + const statusOrError = CustomerStatus.create(source.invoice_status); + const customerSeriesOrError = CustomerSerie.create(source.invoice_series); + const customerNumberOrError = CustomerNumber.create(source.invoice_number); + const issueDateOrError = UtcDate.createFromISO(source.issue_date); + const operationDateOrError = UtcDate.createFromISO(source.operation_date); + + const result = Result.combine([ + idOrError, + statusOrError, + customerSeriesOrError, + customerNumberOrError, + issueDateOrError, + operationDateOrError, + ]); + + if (result.isFailure) { + return Result.fail(result.error); + } + + // Mapear los items de la factura + const itemsOrErrors = this.customerItemMapper.mapArrayToDomain(source.items, { + sourceParent: source, + ...params, + }); + + if (itemsOrErrors.isFailure) { + return Result.fail(itemsOrErrors.error); + } + + const customerCurrency = source.invoice_currency || "EUR"; + + return Customer.create( + { + status: statusOrError.data, + invoiceSeries: customerSeriesOrError.data, + invoiceNumber: customerNumberOrError.data, + issueDate: issueDateOrError.data, + operationDate: operationDateOrError.data, + currency: customerCurrency, + items: itemsOrErrors.data, + }, + idOrError.data + ); + } + + public mapToPersistence(source: Customer, params?: MapperParamsType): CustomerCreationAttributes { + const subtotal = source.calculateSubtotal(); + const total = source.calculateTotal(); + + const items = this.customerItemMapper.mapCollectionToPersistence(source.items, params); + + return { + id: source.id.toString(), + invoice_status: source.status.toPrimitive(), + invoice_series: source.invoiceSeries.toPrimitive(), + invoice_number: source.invoiceNumber.toPrimitive(), + issue_date: source.issueDate.toPrimitive(), + operation_date: source.operationDate.toPrimitive(), + invoice_language: "es", + invoice_currency: source.currency || "EUR", + + subtotal_amount: subtotal.amount, + subtotal_scale: subtotal.scale, + + total_amount: total.amount, + total_scale: total.scale, + + items, + }; + } +} + +const customerMapper: CustomerMapper = new CustomerMapper(); +export { customerMapper }; diff --git a/modules/customers/src/api/infrastructure/mappers/index.ts b/modules/customers/src/api/infrastructure/mappers/index.ts new file mode 100644 index 00000000..7f5fae75 --- /dev/null +++ b/modules/customers/src/api/infrastructure/mappers/index.ts @@ -0,0 +1 @@ +export * from "./customer.mapper"; diff --git a/modules/customers/src/api/infrastructure/sequelize/customer.model.ts b/modules/customers/src/api/infrastructure/sequelize/customer.model.ts new file mode 100644 index 00000000..a3f673a6 --- /dev/null +++ b/modules/customers/src/api/infrastructure/sequelize/customer.model.ts @@ -0,0 +1,175 @@ +import { + CreationOptional, + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, + Sequelize, +} from "sequelize"; + +export type CustomerCreationAttributes = InferCreationAttributes & {}; + +export class CustomerModel extends Model< + InferAttributes, + InferCreationAttributes +> { + // To avoid table creation + /*static async sync(): Promise { + return Promise.resolve(); + }*/ + + declare id: string; + declare reference: CreationOptional; + + declare is_freelancer: boolean; + declare name: string; + declare trade_name: CreationOptional; + declare tin: string; + + declare street: string; + declare city: string; + declare state: string; + declare postal_code: string; + declare country: string; + + declare email: string; + declare phone: string; + declare fax: CreationOptional; + declare website: CreationOptional; + + declare legal_record: string; + + declare default_tax: number; + declare status: string; + declare lang_code: string; + declare currency_code: string; +} + +export default (sequelize: Sequelize) => { + CustomerModel.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + reference: { + type: DataTypes.STRING, + allowNull: false, + }, + is_freelancer: { + type: DataTypes.BOOLEAN, + allowNull: false, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + trade_name: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + tin: { + type: DataTypes.STRING, + allowNull: false, + }, + + street: { + type: DataTypes.STRING, + allowNull: false, + }, + city: { + type: DataTypes.STRING, + allowNull: false, + }, + state: { + type: DataTypes.STRING, + allowNull: false, + }, + postal_code: { + type: DataTypes.STRING, + allowNull: false, + }, + country: { + type: DataTypes.STRING, + allowNull: false, + }, + + email: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isEmail: true, + }, + }, + phone: { + type: DataTypes.STRING, + allowNull: false, + }, + fax: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + website: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + validate: { + isUrl: true, + }, + }, + legal_record: { + type: DataTypes.TEXT, + allowNull: false, + }, + + default_tax: { + type: new DataTypes.SMALLINT(), + allowNull: false, + defaultValue: 2100, + }, + + lang_code: { + type: DataTypes.STRING(2), + allowNull: false, + defaultValue: "es", + }, + + currency_code: { + type: new DataTypes.STRING(3), + allowNull: false, + defaultValue: "EUR", + }, + + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "active", + }, + }, + { + sequelize, + tableName: "customers", + + paranoid: true, // softs deletes + timestamps: true, + + createdAt: "created_at", + updatedAt: "updated_at", + deletedAt: "deleted_at", + + indexes: [ + { name: "email_idx", fields: ["email"], unique: true }, + { name: "reference_idx", fields: ["reference"], unique: true }, + ], + + whereMergeStrategy: "and", // <- cómo tratar el merge de un scope + + defaultScope: {}, + + scopes: {}, + } + ); + return CustomerModel; +}; diff --git a/modules/customers/src/api/infrastructure/sequelize/customer.repository.ts b/modules/customers/src/api/infrastructure/sequelize/customer.repository.ts new file mode 100644 index 00000000..ed4fc3d7 --- /dev/null +++ b/modules/customers/src/api/infrastructure/sequelize/customer.repository.ts @@ -0,0 +1,121 @@ +import { SequelizeRepository, errorMapper } from "@erp/core/api"; +import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Result } from "@repo/rdx-utils"; +import { Sequelize, Transaction } from "sequelize"; +import { Customer, ICustomerRepository } from "../../domain"; +import { ICustomerMapper } from "../mappers/customer.mapper"; +import { CustomerModel } from "./customer.model"; + +export class CustomerRepository + extends SequelizeRepository + implements ICustomerRepository +{ + //private readonly model: typeof CustomerModel; + private readonly mapper!: ICustomerMapper; + + constructor(database: Sequelize, mapper: ICustomerMapper) { + super(database); + + //Customer = database.model("Customer") as typeof CustomerModel; + this.mapper = mapper; + } + + async existsById(id: UniqueID, transaction?: Transaction): Promise> { + try { + const result = await this._exists(CustomerModel, "id", id.toString(), transaction); + + return Result.ok(Boolean(result)); + } catch (err: unknown) { + return Result.fail(errorMapper.toDomainError(err)); + } + } + + /** + * + * Persiste una nueva factura o actualiza una existente. + * + * @param invoice - El agregado a guardar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async save(invoice: Customer, transaction: Transaction): Promise> { + try { + const data = this.mapper.mapToPersistence(invoice); + await CustomerModel.upsert(data, { transaction }); + return Result.ok(invoice); + } catch (err: unknown) { + return Result.fail(errorMapper.toDomainError(err)); + } + } + + /** + * + * Busca una factura por su identificador único. + * @param id - UUID de la factura. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async findById(id: UniqueID, transaction: Transaction): Promise> { + try { + const rawData = await this._findById(CustomerModel, id.toString(), { transaction }); + + if (!rawData) { + return Result.fail(new Error(`Invoice with id ${id} not found.`)); + } + + return this.mapper.mapToDomain(rawData); + } catch (err: unknown) { + return Result.fail(errorMapper.toDomainError(err)); + } + } + + /** + * + * Consulta facturas usando un objeto Criteria (filtros, orden, paginación). + * @param criteria - Criterios de búsqueda. + * @param transaction - Transacción activa para la operación. + * @returns Result + * + * @see Criteria + */ + public async findByCriteria( + criteria: Criteria, + transaction: Transaction + ): Promise, Error>> { + try { + const converter = new CriteriaToSequelizeConverter(); + const query = converter.convert(criteria); + + console.debug({ criteria, transaction, query, CustomerModel }); + + const instances = await CustomerModel.findAll({ + ...query, + transaction, + }); + + console.debug(instances); + + return this.mapper.mapArrayToDomain(instances); + } catch (err: unknown) { + console.error(err); + return Result.fail(errorMapper.toDomainError(err)); + } + } + + /** + * + * Elimina o marca como eliminada una factura. + * @param id - UUID de la factura a eliminar. + * @param transaction - Transacción activa para la operación. + * @returns Result + */ + async deleteById(id: UniqueID, transaction: any): Promise> { + try { + await this._deleteById(CustomerModel, id, false, transaction); + return Result.ok(); + } catch (err: unknown) { + return Result.fail(errorMapper.toDomainError(err)); + } + } +} diff --git a/modules/customers/src/api/infrastructure/sequelize/index.ts b/modules/customers/src/api/infrastructure/sequelize/index.ts new file mode 100644 index 00000000..10c5f64c --- /dev/null +++ b/modules/customers/src/api/infrastructure/sequelize/index.ts @@ -0,0 +1,7 @@ +import customerModelInit from "./customer.model"; + +export * from "./customer.model"; +export * from "./customer.repository"; + +// Array de inicializadores para que registerModels() lo use +export const models = [customerModelInit]; diff --git a/modules/customers/src/common/dto/request/create-customer.command.dto.ts b/modules/customers/src/common/dto/request/create-customer.command.dto.ts new file mode 100644 index 00000000..77d03193 --- /dev/null +++ b/modules/customers/src/common/dto/request/create-customer.command.dto.ts @@ -0,0 +1,36 @@ +import * as z from "zod/v4"; + +export const CreateCustomerCommandSchema = z.object({ + id: z.uuid(), + invoice_status: z.string(), + invoice_number: z.string().min(1, "Customer invoice number is required"), + invoice_series: z.string().min(1, "Customer invoice series is required"), + issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }), + operation_date: z.string().datetime({ offset: true, message: "Invalid operation date format" }), + description: z.string(), + language_code: z.string().min(2, "Language code must be at least 2 characters long"), + currency_code: z.string().min(3, "Currency code must be at least 3 characters long"), + notes: z.string().optional(), + items: z.array( + z.object({ + description: z.string().min(1, "Item description is required"), + quantity: z.object({ + amount: z.number().positive("Quantity amount must be positive"), + scale: z.number().int().nonnegative("Quantity scale must be a non-negative integer"), + }), + unit_price: z.object({ + amount: z.number().positive("Unit price amount must be positive"), + scale: z.number().int().nonnegative("Unit price scale must be a non-negative integer"), + currency_code: z + .string() + .min(3, "Unit price currency code must be at least 3 characters long"), + }), + discount: z.object({ + amount: z.number().nonnegative("Discount amount cannot be negative"), + scale: z.number().int().nonnegative("Discount scale must be a non-negative integer"), + }), + }) + ), +}); + +export type CreateCustomerCommandDTO = z.infer; diff --git a/modules/customers/src/common/dto/request/customer-list.criteria.dto.ts b/modules/customers/src/common/dto/request/customer-list.criteria.dto.ts new file mode 100644 index 00000000..6f2cf837 --- /dev/null +++ b/modules/customers/src/common/dto/request/customer-list.criteria.dto.ts @@ -0,0 +1,33 @@ +import * as z from "zod/v4"; + +/** + * DTO que transporta los parámetros de la consulta (paginación, filtros, etc.) + * para la búsqueda de facturas de cliente. + * + * Este DTO es utilizado por el endpoint: + * `GET /customers` (listado / búsqueda de facturas). + * + */ + +export const CustomerListCriteriaSchema = z.object({ + page: z.number().int().min(1).default(1), + pageSize: z.number().int().min(1).max(100).default(25), + fromDate: z + .string() + .optional() + .refine((val) => !val || !Number.isNaN(Date.parse(val)), { + message: "Invalid date format for fromDate", + }), + toDate: z + .string() + .optional() + .refine((val) => !val || !Number.isNaN(Date.parse(val)), { + message: "Invalid date format for toDate", + }), + status: z.enum(["DRAFT", "POSTED", "PAID", "CANCELLED"]).default("DRAFT"), + customerId: z.string().optional(), + sortBy: z.enum(["issueDate", "totalAmount", "number"]).default("issueDate"), + sortDir: z.enum(["ASC", "DESC"]).default("DESC"), +}); + +export type ListCustomersQueryDTO = z.infer; diff --git a/modules/customers/src/common/dto/request/delete-customer-by-id.params.dto.ts b/modules/customers/src/common/dto/request/delete-customer-by-id.params.dto.ts new file mode 100644 index 00000000..dacf331e --- /dev/null +++ b/modules/customers/src/common/dto/request/delete-customer-by-id.params.dto.ts @@ -0,0 +1,13 @@ +import * as z from "zod/v4"; + +/** + * Este DTO es utilizado por el endpoint: + * `DELETE /customers/:id` (eliminar una factura por ID). + * + */ + +export const DeleteCustomerByIdParamsSchema = z.object({ + id: z.string(), +}); + +export type DeleteCustomerByIdParamsDTO = z.infer; diff --git a/modules/customers/src/common/dto/request/get-customer-by-id.params.dto.ts b/modules/customers/src/common/dto/request/get-customer-by-id.params.dto.ts new file mode 100644 index 00000000..fe4ce811 --- /dev/null +++ b/modules/customers/src/common/dto/request/get-customer-by-id.params.dto.ts @@ -0,0 +1,13 @@ +import * as z from "zod/v4"; + +/** + * Este DTO es utilizado por el endpoint: + * `GET /customers/:id` (consultar una factura por ID). + * + */ + +export const GetCustomerByIdParamsSchema = z.object({ + id: z.string(), +}); + +export type GetCustomerByIdParamsDTO = z.infer; diff --git a/modules/customers/src/common/dto/request/list-customers.query.dto.ts b/modules/customers/src/common/dto/request/list-customers.query.dto.ts deleted file mode 100644 index 36c3a191..00000000 --- a/modules/customers/src/common/dto/request/list-customers.query.dto.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as z from "zod/v4"; - -/** - * DTO que transporta los parámetros de la consulta (paginación, filtros, etc.) - * para la búsqueda de clientes. - * - * Este DTO es utilizado por el endpoint: - * `GET /customers` (listado / búsqueda de clientes). - * - */ - -const operatorEnum = z.enum([ - "CONTAINS", - "NOT_CONTAINS", - "NOT_EQUALS", - "GREATER_THAN", - "GREATER_THAN_OR_EQUAL", - "LOWER_THAN", - "LOWER_THAN_OR_EQUAL", - "EQUALS", -]); - -const filterSchema = z.object({ - field: z.string(), - operator: operatorEnum, - value: z.string(), -}); - -export const ListCustomersQuerySchema = z.object({ - filters: z.array(filterSchema).optional(), - - pageSize: z.coerce.number().int().positive().optional(), - pageNumber: z.coerce.number().int().nonnegative().optional(), - - orderBy: z.string().optional(), - order: z.enum(["asc", "desc"]).default("asc").optional(), -}); - -export type ListCustomersQueryDTO = z.infer; diff --git a/modules/customers/src/common/dto/request/update-customer.command.dto.ts b/modules/customers/src/common/dto/request/update-customer.command.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customers/src/common/dto/response/customer-created.response.dto.ts b/modules/customers/src/common/dto/response/customer-created.response.dto.ts new file mode 100644 index 00000000..5bb77a9e --- /dev/null +++ b/modules/customers/src/common/dto/response/customer-created.response.dto.ts @@ -0,0 +1,17 @@ +import { MetadataSchema } from "@erp/core"; +import * as z from "zod/v4"; + +export const CustomerCreatedResponseSchema = z.object({ + id: z.uuid(), + invoice_status: z.string(), + invoice_number: z.string(), + invoice_series: z.string(), + issue_date: z.iso.datetime({ offset: true }), + operation_date: z.iso.datetime({ offset: true }), + language_code: z.string(), + currency: z.string(), + + metadata: MetadataSchema.optional(), +}); + +export type CustomerCreatedResponseDTO = z.infer; diff --git a/modules/customers/src/common/dto/response/list-customers.result.dto.ts b/modules/customers/src/common/dto/response/customer-list.response.dto.ts similarity index 51% rename from modules/customers/src/common/dto/response/list-customers.result.dto.ts rename to modules/customers/src/common/dto/response/customer-list.response.dto.ts index 26bd94d5..e9d0df4b 100644 --- a/modules/customers/src/common/dto/response/list-customers.result.dto.ts +++ b/modules/customers/src/common/dto/response/customer-list.response.dto.ts @@ -1,14 +1,14 @@ -import { MetadataSchema, createListViewSchema } from "@erp/core"; +import { MetadataSchema, createListViewResultSchema } from "@erp/core"; import * as z from "zod/v4"; -export const ListCustomersResultSchema = createListViewSchema( +export const CustomerListResponseSchema = createListViewResultSchema( z.object({ - id: z.uuid(), - reference: z.string().optional(), + id: z.string(), + reference: z.string(), is_freelancer: z.boolean(), name: z.string(), - trade_name: z.string().optional(), + trade_name: z.string(), tin: z.string(), street: z.string(), @@ -17,8 +17,12 @@ export const ListCustomersResultSchema = createListViewSchema( postal_code: z.string(), country: z.string(), - email: z.email(), + email: z.string(), phone: z.string(), + fax: z.string(), + website: z.string(), + + legal_record: z.string(), default_tax: z.number(), status: z.string(), @@ -29,4 +33,4 @@ export const ListCustomersResultSchema = createListViewSchema( }) ); -export type ListCustomersResultDTO = z.infer; +export type CustomerListResponsetDTO = z.infer; diff --git a/modules/customers/src/common/dto/response/get-customer-by-id.result.dto.ts b/modules/customers/src/common/dto/response/get-customer-by-id.result.dto.ts new file mode 100644 index 00000000..362a75f9 --- /dev/null +++ b/modules/customers/src/common/dto/response/get-customer-by-id.result.dto.ts @@ -0,0 +1,17 @@ +import { MetadataSchema } from "@erp/core"; +import * as z from "zod/v4"; + +export const GetCustomerByIdResultSchema = z.object({ + id: z.uuid(), + invoice_status: z.string(), + invoice_number: z.string(), + invoice_series: z.string(), + issue_date: z.iso.datetime({ offset: true }), + operation_date: z.iso.datetime({ offset: true }), + language_code: z.string(), + currency: z.string(), + + metadata: MetadataSchema.optional(), +}); + +export type GetCustomerByIdResultDTO = z.infer; diff --git a/modules/customers/src/common/dto/response/index.ts b/modules/customers/src/common/dto/response/index.ts index 3c108292..4210e3f1 100644 --- a/modules/customers/src/common/dto/response/index.ts +++ b/modules/customers/src/common/dto/response/index.ts @@ -1 +1 @@ -export * from "./list-customers.result.dto"; +export * from "./customer-list.response.dto"; diff --git a/modules/customers/src/web/components/client-selector.tsx b/modules/customers/src/web/components/client-selector.tsx index d63ce994..8ce76a78 100644 --- a/modules/customers/src/web/components/client-selector.tsx +++ b/modules/customers/src/web/components/client-selector.tsx @@ -3,7 +3,7 @@ import DataTable, { TableColumn } from "react-data-table-component"; import { useDebounce } from "use-debounce"; import { buildTextFilters } from "@erp/core/client"; -import { ListCustomersResultDTO } from "@erp/customer-invoices/common/dto"; +import { ListCustomersResultDTO } from "@erp/customers/common/dto"; import { Badge, Button, @@ -137,11 +137,16 @@ export const ClientSelector = () => { pageNumber, }); + const handleSelectClient = (event): void => { + event.preventDefault(); + setOpen(true); + }; + return (
- @@ -202,9 +207,9 @@ export const ClientSelector = () => { }} pagination paginationServer - paginationPerPage={perPage} - paginationTotalRows={data?.total ?? 0} - onChangePage={(p) => setPage(p)} + paginationPerPage={pageSize} + paginationTotalRows={data?.total_items ?? 0} + onChangePage={(p) => setPageNumber(p)} highlightOnHover pointerOnHover noDataComponent='No se encontraron resultados' diff --git a/modules/customers/src/web/components/customer-prices-card.tsx b/modules/customers/src/web/components/customer-prices-card.tsx new file mode 100644 index 00000000..24ae0e55 --- /dev/null +++ b/modules/customers/src/web/components/customer-prices-card.tsx @@ -0,0 +1,88 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Separator, +} from "@repo/shadcn-ui/components"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "../i18n"; +import { formatCurrency } from "../pages/create/utils"; + +export const CustomerPricesCard = () => { + const { t } = useTranslation(); + const { register, formState, control, watch } = useFormContext(); + + /*const pricesWatch = useWatch({ control, name: ["subtotal_price", "discount", "tax"] }); + + const totals = calculateQuoteTotals(pricesWatch); + + const subtotal_price = formatNumber(totals.subtotalPrice); + const discount_price = formatNumber(totals.discountPrice); + const tax_price = formatNumber(totals.taxesPrice); + const total_price = formatNumber(totals.totalPrice);*/ + + const currency_symbol = watch("currency"); + + return ( + + + Impuestos y Totales + Configuración de impuestos y resumen de totales + + + +
+
+ + {t("form_fields.subtotal_price.label")} + + + {formatCurrency(watch("subtotal_price.amount"), 2, watch("currency"))} + +
+
+ +
+
+ {t("form_fields.discount.label")} +
+
+ + {t("form_fields.discount_price.label")} + + + {"-"} {formatCurrency(watch("discount_price.amount"), 2, watch("currency"))} + +
+
+ +
+
+ {t("form_fields.tax.label")} +
+
+ + {t("form_fields.tax_price.label")} + + + {formatCurrency(watch("tax_price.amount"), 2, watch("currency"))} + +
+
{" "} + +
+
+ + {t("form_fields.total_price.label")} + + + {formatCurrency(watch("total_price.amount"), 2, watch("currency"))} + +
+
+
+
+ ); +}; diff --git a/modules/customers/src/web/components/customer-status-badge.tsx b/modules/customers/src/web/components/customer-status-badge.tsx new file mode 100644 index 00000000..ffb33b5f --- /dev/null +++ b/modules/customers/src/web/components/customer-status-badge.tsx @@ -0,0 +1,65 @@ +import { Badge } from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { forwardRef } from "react"; +import { useTranslation } from "../i18n"; + +export type CustomerStatus = "draft" | "emitted" | "sent" | "received" | "rejected"; + +export type CustomerStatusBadgeProps = { + status: string; // permitir cualquier valor + className?: string; +}; + +const statusColorConfig: Record = { + draft: { + badge: + "bg-gray-600/10 dark:bg-gray-600/20 hover:bg-gray-600/10 text-gray-500 border-gray-600/60", + dot: "bg-gray-500", + }, + emitted: { + badge: + "bg-amber-600/10 dark:bg-amber-600/20 hover:bg-amber-600/10 text-amber-500 border-amber-600/60", + dot: "bg-amber-500", + }, + sent: { + badge: + "bg-cyan-600/10 dark:bg-cyan-600/20 hover:bg-cyan-600/10 text-cyan-500 border-cyan-600/60 shadow-none rounded-full", + dot: "bg-cyan-500", + }, + received: { + badge: + "bg-emerald-600/10 dark:bg-emerald-600/20 hover:bg-emerald-600/10 text-emerald-500 border-emerald-600/60", + dot: "bg-emerald-500", + }, + rejected: { + badge: "bg-red-600/10 dark:bg-red-600/20 hover:bg-red-600/10 text-red-500 border-red-600/60", + dot: "bg-red-500", + }, +}; + +export const CustomerStatusBadge = forwardRef( + ({ status, className, ...props }, ref) => { + const { t } = useTranslation(); + const normalizedStatus = status.toLowerCase() as CustomerStatus; + const config = statusColorConfig[normalizedStatus]; + const commonClassName = + "transition-colors duration-200 cursor-pointer shadow-none rounded-full"; + + if (!config) { + return ( + + {status} + + ); + } + + return ( + +
+ {t(`status.${status}`)} + + ); + } +); + +CustomerStatusBadge.displayName = "CustomerStatusBadge"; diff --git a/modules/customers/src/web/components/customer-taxes-multi-select.tsx b/modules/customers/src/web/components/customer-taxes-multi-select.tsx new file mode 100644 index 00000000..2282ae5a --- /dev/null +++ b/modules/customers/src/web/components/customer-taxes-multi-select.tsx @@ -0,0 +1,73 @@ +import { MultiSelect } from "@repo/rdx-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { useTranslation } from "../i18n"; + +const taxesList = [ + { label: "IVA 21%", value: "iva_21", group: "IVA" }, + { label: "IVA 10%", value: "iva_10", group: "IVA" }, + { label: "IVA 7,5%", value: "iva_7_5", group: "IVA" }, + { label: "IVA 5%", value: "iva_5", group: "IVA" }, + { label: "IVA 4%", value: "iva_4", group: "IVA" }, + { label: "IVA 2%", value: "iva_2", group: "IVA" }, + { label: "IVA 0%", value: "iva_0", group: "IVA" }, + { label: "Exenta", value: "iva_exenta", group: "IVA" }, + { label: "No sujeto", value: "iva_no_sujeto", group: "IVA" }, + { label: "Iva Intracomunitario Bienes", value: "iva_intracomunitario_bienes", group: "IVA" }, + { label: "Iva Intracomunitario Servicio", value: "iva_intracomunitario_servicio", group: "IVA" }, + { label: "Exportación", value: "iva_exportacion", group: "IVA" }, + { label: "Inv. Suj. Pasivo", value: "iva_inversion_sujeto_pasivo", group: "IVA" }, + + { label: "Retención 35%", value: "retencion_35", group: "Retención" }, + { label: "Retención 19%", value: "retencion_19", group: "Retención" }, + { label: "Retención 15%", value: "retencion_15", group: "Retención" }, + { label: "Retención 7%", value: "retencion_7", group: "Retención" }, + { label: "Retención 2%", value: "retencion_2", group: "Retención" }, + + { label: "REC 5,2%", value: "rec_5_2", group: "Recargo de equivalencia" }, + { label: "REC 1,75%", value: "rec_1_75", group: "Recargo de equivalencia" }, + { label: "REC 1,4%", value: "rec_1_4", group: "Recargo de equivalencia" }, + { label: "REC 1%", value: "rec_1", group: "Recargo de equivalencia" }, + { label: "REC 0,62%", value: "rec_0_62", group: "Recargo de equivalencia" }, + { label: "REC 0,5%", value: "rec_0_5", group: "Recargo de equivalencia" }, + { label: "REC 0,26%", value: "rec_0_26", group: "Recargo de equivalencia" }, + { label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" }, +]; + +interface CustomerTaxesMultiSelect { + value: string[]; + onChange: (selectedValues: string[]) => void; + [key: string]: any; // Allow other props to be passed +} + +export const CustomerTaxesMultiSelect = (props: CustomerTaxesMultiSelect) => { + const { value, onChange, ...otherProps } = props; + const { t } = useTranslation(); + + const handleOnChange = (selectedValues: string[]) => { + onChange(selectedValues); + }; + + const handleValidateOption = (candidateValue: string) => { + const exists = (value || []).some((item) => item.startsWith(candidateValue.substring(0, 3))); + if (exists) { + alert(t("components.customer_invoice_taxes_multi_select.invalid_tax_selection")); + } + return exists === false; + }; + + return ( +
+ +
+ ); +}; diff --git a/modules/customers/src/web/components/customers-layout.tsx b/modules/customers/src/web/components/customers-layout.tsx new file mode 100644 index 00000000..e971ea47 --- /dev/null +++ b/modules/customers/src/web/components/customers-layout.tsx @@ -0,0 +1,6 @@ +import { PropsWithChildren } from "react"; +import { CustomersProvider } from "../context"; + +export const CustomersLayout = ({ children }: PropsWithChildren) => { + return {children}; +}; diff --git a/modules/customers/src/web/components/customers-list-grid.tsx b/modules/customers/src/web/components/customers-list-grid.tsx new file mode 100644 index 00000000..2e24eb68 --- /dev/null +++ b/modules/customers/src/web/components/customers-list-grid.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; + +import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale"; +// Grid +import type { ColDef, GridOptions, ValueFormatterParams } from "ag-grid-community"; +import { AllCommunityModule, ModuleRegistry } from "ag-grid-community"; + +ModuleRegistry.registerModules([AllCommunityModule]); + +import { MoneyDTO } from "@erp/core"; +import { formatDate, formatMoney } from "@erp/core/client"; +// Core CSS +import { AgGridReact } from "ag-grid-react"; +import { useCustomersQuery } from "../hooks"; +import { useTranslation } from "../i18n"; +import { CustomerStatusBadge } from "./customer-status-badge"; + +// Create new GridExample component +export const CustomersListGrid = () => { + const { t } = useTranslation(); + const { data, isLoading, isPending, isError, error } = useCustomersQuery({}); + + // Column Definitions: Defines & controls grid columns. + const [colDefs] = useState([ + { + field: "invoice_status", + filter: true, + headerName: t("pages.list.grid_columns.invoice_status"), + cellRenderer: (params: ValueFormatterParams) => { + return ; + }, + }, + + { field: "invoice_number", headerName: t("pages.list.grid_columns.invoice_number") }, + { field: "invoice_series", headerName: t("pages.list.grid_columns.invoice_series") }, + + { + field: "issue_date", + headerName: t("pages.list.grid_columns.issue_date"), + valueFormatter: (params: ValueFormatterParams) => { + return formatDate(params.value); + }, + }, + { + field: "total_price", + headerName: t("pages.list.grid_columns.total_price"), + valueFormatter: (params: ValueFormatterParams) => { + const rawValue: MoneyDTO = params.value; + return formatMoney(rawValue); + }, + }, + ]); + + const gridOptions: GridOptions = { + columnDefs: colDefs, + defaultColDef: { + editable: true, + flex: 1, + minWidth: 100, + filter: false, + sortable: false, + resizable: true, + }, + pagination: true, + paginationPageSize: 10, + paginationPageSizeSelector: [10, 20, 30, 50], + localeText: AG_GRID_LOCALE_ES, + rowSelection: { mode: "multiRow" }, + }; + + // Container: Defines the grid's theme & dimensions. + return ( +
+ +
+ ); +}; diff --git a/modules/customers/src/web/context/customers-context.tsx b/modules/customers/src/web/context/customers-context.tsx new file mode 100644 index 00000000..445d4f4a --- /dev/null +++ b/modules/customers/src/web/context/customers-context.tsx @@ -0,0 +1,55 @@ +import { PropsWithChildren, createContext } from "react"; + +/** + * ──────────────────────────────────────────────────────────────────────────────── + * 💡 Posibles usos del InvoicingContext + * ──────────────────────────────────────────────────────────────────────────────── + * Este contexto se diseña para encapsular estado y lógica compartida dentro del + * bounded context de facturación (facturas), proporcionando acceso global a datos + * o funciones relevantes para múltiples vistas (listado, detalle, edición, etc). + * + * ✅ Usos recomendados: + * + * 1. 🔎 Gestión de filtros globales: + * - Permite que los filtros aplicados en el listado de facturas se conserven + * cuando el usuario navega a otras vistas (detalle, edición) y luego regresa. + * - Mejora la experiencia de usuario evitando la necesidad de reestablecer filtros. + * + * 2. 🛡️ Gestión de permisos o configuración de acciones disponibles: + * - Permite definir qué acciones están habilitadas para el usuario actual + * (crear, editar, eliminar). + * - Útil para mostrar u ocultar botones de acción en diferentes pantallas. + * + * 3. 🧭 Control del layout: + * - Si el layout tiene elementos dinámicos (tabs, breadcrumb, loading global), + * este contexto puede coordinar su estado desde componentes hijos. + * - Ejemplo: seleccionar una pestaña activa que aplica en todas las subrutas. + * + * 4. 📦 Cacheo liviano de datos compartidos: + * - Puede almacenar la última factura abierta, borradores de edición, + * o referencias temporales para operaciones CRUD sin tener que usar la URL. + * + * 5. 🚀 Coordinación de side-effects: + * - Permite exponer funciones comunes como `refetch`, `resetFilters`, + * o `notifyInvoiceChanged`, usadas desde cualquier subcomponente del dominio. + * + * ⚠️ Alternativas: + * - Si el estado compartido es muy mutable, grande o requiere persistencia, + * podría ser preferible usar Zustand o Redux Toolkit. + * - No usar contextos para valores que cambian frecuentemente en tiempo real, + * ya que pueden causar renders innecesarios. + * + * ──────────────────────────────────────────────────────────────────────────────── + */ + +export type CustomersContextType = {}; + +export type CustomersContextParamsType = { + //service: CustomerService; +}; + +export const CustomersContext = createContext({}); + +export const CustomersProvider = ({ children }: PropsWithChildren) => { + return {children}; +}; diff --git a/modules/customers/src/web/customer-routes.tsx b/modules/customers/src/web/customer-routes.tsx new file mode 100644 index 00000000..816a99cb --- /dev/null +++ b/modules/customers/src/web/customer-routes.tsx @@ -0,0 +1,61 @@ +import { ModuleClientParams } from "@erp/core/client"; +import { lazy } from "react"; +import { Outlet, RouteObject } from "react-router-dom"; + +// Lazy load components +const CustomersLayout = lazy(() => + import("./components").then((m) => ({ default: m.CustomersLayout })) +); + +const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersList }))); + +const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreate }))); + +//const LogoutPage = lazy(() => import("./app").then((m) => ({ default: m.LogoutPage }))); + +/*const DealerLayout = lazy(() => import("./app").then((m) => ({ default: m.DealerLayout }))); +const DealersList = lazy(() => import("./app").then((m) => ({ default: m.DealersList }))); + +const LoginPageWithLanguageSelector = lazy(() => + import("./app").then((m) => ({ default: m.LoginPageWithLanguageSelector })) +); + + +const CustomerEdit = lazy(() => import("./app").then((m) => ({ default: m.CustomerEdit }))); +const SettingsEditor = lazy(() => import("./app").then((m) => ({ default: m.SettingsEditor }))); +const SettingsLayout = lazy(() => import("./app").then((m) => ({ default: m.SettingsLayout }))); +const CatalogLayout = lazy(() => import("./app").then((m) => ({ default: m.CatalogLayout }))); +const CatalogList = lazy(() => import("./app").then((m) => ({ default: m.CatalogList }))); +const DashboardPage = lazy(() => import("./app").then((m) => ({ default: m.DashboardPage }))); +const CustomersLayout = lazy(() => import("./app").then((m) => ({ default: m.CustomersLayout }))); +const CustomersList = lazy(() => import("./app").then((m) => ({ default: m.CustomersList })));*/ + +export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => { + return [ + { + path: "customers", + element: ( + + + + ), + children: [ + { path: "", index: true, element: }, // index + { path: "list", element: }, + { path: "create", element: }, + + // + /*{ path: "create", element: }, + { path: ":id", element: }, + { path: ":id/edit", element: }, + { path: ":id/delete", element: }, + { path: ":id/view", element: }, + { path: ":id/print", element: }, + { path: ":id/email", element: }, + { path: ":id/download", element: }, + { path: ":id/duplicate", element: }, + { path: ":id/preview", element: },*/ + ], + }, + ]; +}; diff --git a/modules/customers/src/web/hooks/use-create-customer-mutation.ts b/modules/customers/src/web/hooks/use-create-customer-mutation.ts new file mode 100644 index 00000000..30de0b0c --- /dev/null +++ b/modules/customers/src/web/hooks/use-create-customer-mutation.ts @@ -0,0 +1,19 @@ +import { useDataSource, useQueryKey } from "@erp/core/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ICreateCustomerRequestDTO } from "../../common/dto"; + +export const useCreateCustomerMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + const keys = useQueryKey(); + + return useMutation>({ + mutationFn: (data) => { + console.log(data); + return dataSource.createOne("customers", data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["customers"] }); + }, + }); +}; diff --git a/modules/customers/src/web/hooks/use-customers-context.tsx b/modules/customers/src/web/hooks/use-customers-context.tsx new file mode 100644 index 00000000..663d8d14 --- /dev/null +++ b/modules/customers/src/web/hooks/use-customers-context.tsx @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { CustomersContext, CustomersContextType } from "../context"; + +export const useCustomersContext = (): CustomersContextType => { + const context = useContext(CustomersContext); + if (!context) { + throw new Error("useCustomers must be used within a CustomersProvider"); + } + + return context; +}; diff --git a/modules/customers/src/web/hooks/use-customers-query.tsx b/modules/customers/src/web/hooks/use-customers-query.tsx index 720f29e6..70b71194 100644 --- a/modules/customers/src/web/hooks/use-customers-query.tsx +++ b/modules/customers/src/web/hooks/use-customers-query.tsx @@ -1,19 +1,18 @@ import { useDataSource, useQueryKey } from "@erp/core/client"; -import { ListCustomersQueryDTO, ListCustomersResultDTO } from "@erp/customers"; -import { UseQueryResult, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; +import { CustomerListResponsetDTO } from "../../common/dto"; -// Obtener clientes -export const useCustomersQuery = ( - params: ListCustomersQueryDTO -): UseQueryResult => { +// Obtener todas las facturas +export const useCustomersQuery = (params: any) => { const dataSource = useDataSource(); const keys = useQueryKey(); - return useQuery({ + return useQuery({ queryKey: keys().data().resource("customers").action("list").params(params).get(), queryFn: (context) => { + console.log(dataSource.getBaseUrl()); const { signal } = context; - return dataSource.getList("customers", { + return dataSource.getList("customers", { signal, ...params, }); diff --git a/modules/customers/src/web/hooks/use-customers.bak b/modules/customers/src/web/hooks/use-customers.bak new file mode 100644 index 00000000..b7459ab6 --- /dev/null +++ b/modules/customers/src/web/hooks/use-customers.bak @@ -0,0 +1,75 @@ +import { useDataSource, useQueryKey } from "@erp/core/client"; +import { IListCustomersResponseDTO } from "@erp/customers/common/dto"; + +export type UseCustomersListParams = Omit & { + status?: string; + enabled?: boolean; + queryOptions?: Record; +}; + +export type UseCustomersListResponse = UseListQueryResult< + IListResponseDTO, + unknown +>; + +export type UseCustomersGetParamsType = { + enabled?: boolean; + queryOptions?: Record; +}; + +export type UseCustomersReportParamsType = { + enabled?: boolean; + queryOptions?: Record; +}; + +export const useCustomers = () => { + const actions = { + /** + * Hook para obtener la lista de facturas + * @param params - Parámetros para la consulta de la lista de facturas + * @returns - Respuesta de la consulta de la lista de facturas + */ + useList: (params: UseCustomersListParams): UseCustomersListResponse => { + const dataSource = useDataSource(); + const keys = useQueryKey(); + + const { + pagination, + status = "draft", + quickSearchTerm = undefined, + enabled = true, + queryOptions, + } = params; + + return useList({ + queryKey: keys().data().resource("customers").action("list").params(params).get(), + queryFn: () => { + return dataSource.getList({ + resource: "customers", + quickSearchTerm, + filters: + status !== "all" + ? [ + { + field: "status", + operator: "eq", + value: status, + }, + ] + : [ + { + field: "status", + operator: "ne", + value: "archived", + }, + ], + pagination, + }); + }, + enabled, + queryOptions, + }); + }, + }; + return actions; +}; diff --git a/modules/customers/src/web/manifest.ts b/modules/customers/src/web/manifest.ts index 9344a67e..b9e46c59 100644 --- a/modules/customers/src/web/manifest.ts +++ b/modules/customers/src/web/manifest.ts @@ -15,7 +15,7 @@ export const CustomersModuleManifiest: IModuleClient = { routes: (params: ModuleClientParams) => { //i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true); //i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true); - //return CustomerInvoiceRoutes(params); + //return CustomerRoutes(params); return []; }, }; diff --git a/package.json b/package.json index e4158d08..d343ac17 100644 --- a/package.json +++ b/package.json @@ -31,5 +31,5 @@ "engines": { "node": ">=18" }, - "packageManager": "pnpm@10.11.0" + "packageManager": "pnpm@10.14.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dec9aef2..8a7056f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@erp/customer-invoices': specifier: workspace:* version: link:../../modules/customer-invoices + '@erp/customers': + specifier: workspace:* + version: link:../../modules/customers bcrypt: specifier: ^5.1.1 version: 5.1.1 @@ -120,7 +123,7 @@ importers: specifier: ^5.0.0 version: 5.0.0(winston@3.17.0) zod: - specifier: ^3.24.1 + specifier: ^3.25.67 version: 3.25.67 devDependencies: '@biomejs/biome': @@ -173,7 +176,7 @@ importers: version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -399,9 +402,6 @@ importers: '@biomejs/biome': specifier: 1.9.4 version: 1.9.4 - '@testing-library/react-hooks': - specifier: ^8.0.1 - version: 8.0.1(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/axios': specifier: ^0.14.4 version: 0.14.4 @@ -559,9 +559,21 @@ importers: '@ag-grid-community/locale': specifier: 34.0.0 version: 34.0.0 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.0) '@erp/core': specifier: workspace:* version: link:../core + '@erp/customers': + specifier: workspace:* + version: 'link:' '@hookform/resolvers': specifier: ^5.0.1 version: 5.1.1(react-hook-form@7.58.1(react@19.1.0)) @@ -583,6 +595,9 @@ importers: '@tanstack/react-query': specifier: ^5.74.11 version: 5.81.2(react@19.1.0) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ag-grid-community: specifier: ^33.3.0 version: 33.3.2 @@ -592,6 +607,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + dinero.js: + specifier: ^1.9.1 + version: 1.9.1 express: specifier: ^4.18.2 version: 4.21.2 @@ -604,9 +622,6 @@ importers: react: specifier: ^19.1.0 version: 19.1.0 - react-data-table-component: - specifier: ^7.7.0 - version: 7.7.0(react@19.1.0)(styled-components@6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) @@ -625,18 +640,15 @@ importers: slugify: specifier: ^1.6.6 version: 1.6.6 + sonner: + specifier: ^2.0.5 + version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwindcss: specifier: ^4.1.11 version: 4.1.11 tw-animate-css: - specifier: ^1.3.5 + specifier: ^1.3.4 version: 1.3.5 - use-debounce: - specifier: ^10.0.5 - version: 10.0.5(react@19.1.0) - use-query: - specifier: ^1.0.2 - version: 1.0.2 zod: specifier: ^3.25.67 version: 3.25.67 @@ -644,6 +656,12 @@ importers: '@biomejs/biome': specifier: 1.9.4 version: 1.9.4 + '@hookform/devtools': + specifier: ^4.4.0 + version: 4.4.0(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@types/dinero.js': + specifier: ^1.9.4 + version: 1.9.4 '@types/express': specifier: ^4.17.21 version: 4.17.23 @@ -1342,15 +1360,9 @@ packages: '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - '@emotion/is-prop-valid@1.2.2': - resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==} - '@emotion/is-prop-valid@1.3.1': resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} - '@emotion/memoize@0.8.1': - resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} - '@emotion/memoize@0.9.0': resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} @@ -1382,9 +1394,6 @@ packages: '@emotion/unitless@0.10.0': resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} - '@emotion/unitless@0.8.1': - resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} - '@emotion/use-insertion-effect-with-fallbacks@1.2.0': resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} peerDependencies: @@ -2865,22 +2874,6 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@testing-library/react-hooks@8.0.1': - resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} - engines: {node: '>=12'} - peerDependencies: - '@types/react': ^16.9.0 || ^17.0.0 - react: ^16.9.0 || ^17.0.0 - react-dom: ^16.9.0 || ^17.0.0 - react-test-renderer: ^16.9.0 || ^17.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - react-dom: - optional: true - react-test-renderer: - optional: true - '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -3076,9 +3069,6 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/stylis@4.2.5': - resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==} - '@types/through@0.0.33': resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} @@ -3370,9 +3360,6 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - camelize@1.0.1: - resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - caniuse-lite@1.0.30001720: resolution: {integrity: sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==} @@ -3603,16 +3590,9 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - css-color-keywords@1.0.0: - resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} - engines: {node: '>=4'} - css-select@4.3.0: resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} - css-to-react-native@3.2.0: - resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} - css-what@6.1.0: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} @@ -5380,10 +5360,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.49: - resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -5448,12 +5424,6 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-data-table-component@7.7.0: - resolution: {integrity: sha512-5knL6zMSKlbvzu9P04KM5Lx8/EyQujb4I9z3rWeoVX++IDJadQ7aR4X5J6EeS90wjK0Xoa6btaVeglnCAqD2ag==} - peerDependencies: - react: '>= 17.0.0' - styled-components: '>= 5.0.0' - react-day-picker@8.10.1: resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} peerDependencies: @@ -5465,12 +5435,6 @@ packages: peerDependencies: react: ^19.1.0 - react-error-boundary@3.1.4: - resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} - engines: {node: '>=10', npm: '>=6'} - peerDependencies: - react: '>=16.13.1' - react-hook-form-persist@3.0.0: resolution: {integrity: sha512-6nwW65JyFpBem9RjLYAWvIFxOLoCk0E13iB9e5yeF5jeHlwx1ua0M77FvwhPpD8eaCz7hG4ziCdOxRcnJVUSxQ==} peerDependencies: @@ -5794,9 +5758,6 @@ packages: shallow-equal-object@1.1.1: resolution: {integrity: sha512-9DDzYRlzCwF2CemeF0aOFk5T5KMrjG7HldcW7utwYhA/limuGHn3No8KhpDE8BrO7GLaSRJumNKReipZBybd7A==} - shallowequal@1.1.0: - resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5956,19 +5917,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - styled-components@6.1.19: - resolution: {integrity: sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==} - engines: {node: '>= 16'} - peerDependencies: - react: '>= 16.8.0' - react-dom: '>= 16.8.0' - stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} - stylis@4.3.2: - resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==} - stylus@0.62.0: resolution: {integrity: sha512-v3YCf31atbwJQIMtPNX8hcQ+okD4NQaTuKGUWfII8eaqn+3otrbttGL1zSMZAAtiPsBztQnujVBugg/cXFUpyg==} hasBin: true @@ -6162,9 +6113,6 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -6305,22 +6253,12 @@ packages: '@types/react': optional: true - use-debounce@10.0.5: - resolution: {integrity: sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==} - engines: {node: '>= 16.0.0'} - peerDependencies: - react: '*' - use-deep-compare-effect@1.8.1: resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==} engines: {node: '>=10', npm: '>=6'} peerDependencies: react: '>=16.13' - use-query@1.0.2: - resolution: {integrity: sha512-Ypdv/LMbs4OnjCCZ4QtWVCu5XKUUiHZSf0X0dToZahX9BXs5LmVkBCgLN8PVEGcuulNI7fL1SOukFGesWhO2EA==} - engines: {node: '>=6.0.0'} - use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -6901,16 +6839,10 @@ snapshots: '@emotion/hash@0.9.2': {} - '@emotion/is-prop-valid@1.2.2': - dependencies: - '@emotion/memoize': 0.8.1 - '@emotion/is-prop-valid@1.3.1': dependencies: '@emotion/memoize': 0.9.0 - '@emotion/memoize@0.8.1': {} - '@emotion/memoize@0.9.0': {} '@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0)': @@ -6956,8 +6888,6 @@ snapshots: '@emotion/unitless@0.10.0': {} - '@emotion/unitless@0.8.1': {} - '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.1.0)': dependencies: react: 19.1.0 @@ -8442,15 +8372,6 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@testing-library/react-hooks@8.0.1(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@babel/runtime': 7.27.3 - react: 19.1.0 - react-error-boundary: 3.1.4(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.8 - react-dom: 19.1.0(react@19.1.0) - '@tootallnate/quickjs-emscripten@0.23.0': {} '@tsconfig/node10@1.0.11': {} @@ -8714,8 +8635,6 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@types/stylis@4.2.5': {} - '@types/through@0.0.33': dependencies: '@types/node': 22.15.32 @@ -9046,8 +8965,6 @@ snapshots: camelcase@6.3.0: {} - camelize@1.0.1: {} - caniuse-lite@1.0.30001720: {} case@1.6.3: {} @@ -9287,8 +9204,6 @@ snapshots: crypto-js@4.2.0: {} - css-color-keywords@1.0.0: {} - css-select@4.3.0: dependencies: boolbase: 1.0.0 @@ -9297,12 +9212,6 @@ snapshots: domutils: 2.8.0 nth-check: 2.1.1 - css-to-react-native@3.2.0: - dependencies: - camelize: 1.0.1 - css-color-keywords: 1.0.0 - postcss-value-parser: 4.2.0 - css-what@6.1.0: {} cssesc@3.0.0: {} @@ -11216,12 +11125,6 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.49: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -11298,12 +11201,6 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-data-table-component@7.7.0(react@19.1.0)(styled-components@6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): - dependencies: - deepmerge: 4.3.1 - react: 19.1.0 - styled-components: 6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react-day-picker@8.10.1(date-fns@4.1.0)(react@19.1.0): dependencies: date-fns: 4.1.0 @@ -11314,11 +11211,6 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-error-boundary@3.1.4(react@19.1.0): - dependencies: - '@babel/runtime': 7.27.3 - react: 19.1.0 - react-hook-form-persist@3.0.0(react-hook-form@7.58.1(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -11635,8 +11527,6 @@ snapshots: shallow-equal-object@1.1.1: {} - shallowequal@1.1.0: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -11786,24 +11676,8 @@ snapshots: strip-json-comments@3.1.1: {} - styled-components@6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - '@emotion/is-prop-valid': 1.2.2 - '@emotion/unitless': 0.8.1 - '@types/stylis': 4.2.5 - css-to-react-native: 3.2.0 - csstype: 3.1.3 - postcss: 8.4.49 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - shallowequal: 1.1.0 - stylis: 4.3.2 - tslib: 2.6.2 - stylis@4.2.0: {} - stylis@4.3.2: {} - stylus@0.62.0: dependencies: '@adobe/css-tools': 4.3.3 @@ -11957,7 +11831,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3): + ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -11975,6 +11849,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.27.4) + esbuild: 0.25.5 jest-util: 29.7.0 ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3): @@ -12046,8 +11921,6 @@ snapshots: tslib@1.14.1: {} - tslib@2.6.2: {} - tslib@2.8.1: {} tsup@8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3): @@ -12189,18 +12062,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 - use-debounce@10.0.5(react@19.1.0): - dependencies: - react: 19.1.0 - use-deep-compare-effect@1.8.1(react@19.1.0): dependencies: '@babel/runtime': 7.27.6 dequal: 2.0.3 react: 19.1.0 - use-query@1.0.2: {} - use-sidecar@1.1.3(@types/react@19.1.8)(react@19.1.0): dependencies: detect-node-es: 1.1.0