From 53eb33376c0d3c3ac0f3d40835da64f47f66e9c5 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 20 Apr 2026 19:20:42 +0200 Subject: [PATCH] Muchos cambios --- .vscode/extensions.json | 3 +- .../presentation/dto/accounts.schemas.ts | 8 +- docs/DTOS - GUIA DE ESTILO.md | 150 ---- docs/DTOS - GUÍA DE DISEÑO.md | 667 +++++++----------- .../express/api-error-mapper.ts | 8 +- .../core/src/common/dto/amount-money.dto.ts | 5 +- .../core/src/common/dto/country-code.dto.ts | 13 + .../core/src/common/dto/currency-code.dto.ts | 13 + modules/core/src/common/dto/email.dto.ts | 5 + modules/core/src/common/dto/index.ts | 10 + modules/core/src/common/dto/iso-date-dto.ts | 51 ++ modules/core/src/common/dto/land-phone.dto.ts | 5 + .../core/src/common/dto/language-code.dto.ts | 13 + .../core/src/common/dto/mobile-phone.dto.ts | 5 + .../core/src/common/dto/postal-code.dto.ts | 21 + modules/core/src/common/dto/tin.dto.ts | 23 + modules/core/src/common/dto/url.dto.ts | 7 + .../models/issued-invoice-summary.ts | 2 + .../issued-invoice-full-snapshot-builder.ts | 40 +- .../issued-invoice-full-snapshot.interface.ts | 30 +- ...ed-invoice-item-full-snapshot.interface.ts | 6 +- ...ued-invoice-items-full-snapshot-builder.ts | 7 +- ...invoice-recipient-full-snapshot-builder.ts | 40 +- ...voice-recipient-full-snapshot.interface.ts | 28 +- ...ued-invoice-tax-full-snapshot-interface.ts | 4 +- ...ued-invoice-taxes-full-snapshot-builder.ts | 6 +- ...voice-verifactu-full-snapshot.interface.ts | 8 +- ...issued-invoice-summary-snapshot-builder.ts | 31 +- ...sued-invoice-summary-snapshot.interface.ts | 28 +- .../mappers/create-proforma-input.mapper.ts | 29 +- .../mappers/update-proforma-input.mapper.ts | 25 +- .../full/proforma-full-snapshot-builder.ts | 17 +- .../full/proforma-full-snapshot.interface.ts | 27 +- .../proforma-item-full-snapshot.interface.ts | 6 +- .../proforma-items-full-snapshot-builder.ts | 7 +- ...roforma-recipient-full-snapshot-builder.ts | 37 +- ...forma-recipient-full-snapshot.interface.ts | 22 +- .../proforma-summary-snapshot-builder.ts | 22 +- .../proforma-summary-snapshot.interface.ts | 32 +- .../invoice-recipient/invoice-recipient.vo.ts | 14 +- .../aggregates/issued-invoice.aggregate.ts | 22 +- .../aggregates/proforma.aggregate.ts | 22 +- .../get-issued-invoice-by-id.controller.ts | 1 + .../sequelize-issued-invoice-domain.mapper.ts | 31 +- ...-issued-invoice-recipient-domain.mapper.ts | 10 +- ...equelize-verifactu-record-domain.mapper.ts | 2 +- ...sequelize-issued-invoice-summary.mapper.ts | 10 + .../sequelize-proforma-domain.mapper.ts | 13 +- .../sequelize-proforma-item-domain.mapper.ts | 2 +- .../repositories/proforma.repository.ts | 6 + .../customer-invoices/src/common/dto/index.ts | 1 + .../get-issued-invoice-by-id.response.dto.ts | 125 +--- .../list-issued-invoices.response.dto.ts | 49 +- .../get-proforma-by-id.response.dto.ts | 119 +--- .../proformas/list-proformas.response.dto.ts | 42 +- .../src/common/dto/shared/index.ts | 5 + .../dto/shared/issued-invoices/index.ts | 4 + .../issued-invoice-item-detail.dto.ts | 28 + .../issued-invoice-recipient-summary.dto.ts | 16 + .../issued-invoice-status.dto.ts | 5 + .../issued-invoices/verifactu-record.dto.ts | 13 + .../dto/shared/item-taxes-breakdown.dto.ts | 6 + .../dto/shared/payment-methof-ref.dto.ts | 8 + .../src/common/dto/shared/proforma/index.ts | 3 + .../proforma/proforma-item-detail.dto.ts | 28 + .../proforma-recipient-summary.dto.ts | 16 + .../shared/proforma/proforma-status.dto.ts | 5 + .../common/dto/shared/taxes-breakdown.dto.ts | 22 + .../pages/create/customer-invoice.schema.ts | 10 +- .../web/proformas/update/ui/blocks/index.ts | 2 - .../editors/proforma-update-editor-form.tsx | 5 - .../editors/proforma-update-header-editor.tsx | 17 +- .../editors/proforma-update-items-editor.tsx | 6 +- .../proforma-update-recipient-editor.tsx | 6 +- .../update/ui/pages/proforma-update-page.tsx | 22 +- .../mappers/update-customer-input.mapper.ts | 266 ++++--- .../domain/customer-snapshot-builder.ts | 59 -- .../domain/customer-snapshot.interface.ts | 39 - .../snapshot-builders/domain/index.ts | 2 - .../full/customer-full-snapshot-builder.ts | 60 ++ .../snapshot-builders/full/index.ts | 1 + .../application/snapshot-builders/index.ts | 2 +- .../customer-summary-snapshot-builder.ts | 49 +- .../customer-summary-snapshot.interface.ts | 35 - .../snapshot-builders/summary/index.ts | 1 - .../domain/aggregates/customer.aggregate.ts | 89 ++- .../domain/value-objects/customer-taxes.vo.ts | 5 - .../sequelize/mappers/domain/index.ts | 2 +- ...ts => sequelize-customer-domain.mapper.ts} | 15 +- .../update-customer-by-id.request.dto.ts | 74 +- .../get-customer-by-id.response.dto.ts | 66 +- .../src/common/dto/response/index.ts | 2 + .../response/list-customers.response.dto.ts | 39 +- .../common/dto/shared/customer-status.dto.ts | 5 + .../common/dto/shared/customer-summary.dto.ts | 41 ++ .../customers/src/common/dto/shared/index.ts | 2 + .../use-customer-grid-columns.tsx | 39 +- .../web/list/ui/components/address-cell.tsx | 57 +- .../web/list/ui/components/contact-cell.tsx | 136 ++-- .../adapters/get-customer-by-id.adapter.ts | 39 +- .../shared/adapters/list-customers.adapter.ts | 32 +- .../entities/customer-list-row.entity.ts | 38 +- .../web/shared/entities/customer.entity.ts | 42 +- ...ustomer-to-customer-update-form.adapter.ts | 62 +- .../use-customer-update.controller.ts | 9 +- .../entities/customer-update-form.schema.ts | 7 +- .../entities/customer-update-patch.entity.ts | 48 +- .../customer-additional-config-fields.tsx | 79 +-- .../ui/editor/customer-address-editor.tsx | 86 +++ .../ui/editor/customer-address-fields.tsx | 76 -- ...lds.tsx => customer-basic-info-editor.tsx} | 72 +- .../ui/editor/customer-contact-fields.tsx | 122 ++-- .../update/ui/editor/customer-edit-form.tsx | 28 +- .../update/ui/pages/customer-update-page.tsx | 109 ++- .../utils/build-customer.update-patch.ts | 130 +++- .../build-update-customer-by-id-params.ts | 34 +- .../customers/src/web/update/utils/index.ts | 1 + .../jobs/supplier-invoice-processing.job.ts | 0 .../sequelize/mappers/domain/index.ts | 2 +- ...quelize-supplier-invoice-domain.mapper.ts} | 85 +-- ...ze-supplier-invoice-item-domain.mapper.ts} | 28 +- ...pplier-invoice-recipient-domain.mapper.ts} | 14 +- ...e-supplier-invoice-taxes-domain.mapper.ts} | 22 +- ...equelize-verifactu-record-domain.mapper.ts | 135 ---- .../sequelize/mappers/summary/index.ts | 2 +- ...plier-invoice-recipient-summary.mapper.ts} | 0 ...uelize-supplier-invoice-summary.mapper.ts} | 2 +- .../get-supplier-by-id.response.dto.ts | 16 +- .../response/list-suppliers.response.dto.ts | 19 +- .../src/errors/validation-error-collection.ts | 2 +- packages/rdx-ddd/src/helpers/normalizers.ts | 232 ++++-- .../src/value-objects/postal-address.ts | 14 +- .../src/components/form/form-section-card.tsx | 6 +- .../src/components/form/form-section-grid.tsx | 2 +- packages/rdx-ui/src/components/form/index.ts | 2 + .../rdx-ui/src/components/multi-select.tsx | 134 ++-- packages/rdx-utils/src/helpers/maybe.ts | 9 +- packages/rdx-utils/src/helpers/patch-field.ts | 149 +++- packages/rdx-utils/tsconfig.json | 2 +- 139 files changed, 2722 insertions(+), 2412 deletions(-) delete mode 100644 docs/DTOS - GUIA DE ESTILO.md create mode 100644 modules/core/src/common/dto/country-code.dto.ts create mode 100644 modules/core/src/common/dto/currency-code.dto.ts create mode 100644 modules/core/src/common/dto/email.dto.ts create mode 100644 modules/core/src/common/dto/iso-date-dto.ts create mode 100644 modules/core/src/common/dto/land-phone.dto.ts create mode 100644 modules/core/src/common/dto/language-code.dto.ts create mode 100644 modules/core/src/common/dto/mobile-phone.dto.ts create mode 100644 modules/core/src/common/dto/postal-code.dto.ts create mode 100644 modules/core/src/common/dto/tin.dto.ts create mode 100644 modules/core/src/common/dto/url.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/index.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-item-detail.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-recipient-summary.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-status.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/issued-invoices/verifactu-record.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/item-taxes-breakdown.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/payment-methof-ref.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/proforma/index.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/proforma/proforma-item-detail.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/proforma/proforma-recipient-summary.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/proforma/proforma-status.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/taxes-breakdown.dto.ts delete mode 100644 modules/customers/src/api/application/snapshot-builders/domain/customer-snapshot-builder.ts delete mode 100644 modules/customers/src/api/application/snapshot-builders/domain/customer-snapshot.interface.ts delete mode 100644 modules/customers/src/api/application/snapshot-builders/domain/index.ts create mode 100644 modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts create mode 100644 modules/customers/src/api/application/snapshot-builders/full/index.ts delete mode 100644 modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot.interface.ts rename modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/{sequelize-customer.mapper.ts => sequelize-customer-domain.mapper.ts} (97%) create mode 100644 modules/customers/src/common/dto/shared/customer-status.dto.ts create mode 100644 modules/customers/src/common/dto/shared/customer-summary.dto.ts create mode 100644 modules/customers/src/common/dto/shared/index.ts create mode 100644 modules/customers/src/web/update/ui/editor/customer-address-editor.tsx delete mode 100644 modules/customers/src/web/update/ui/editor/customer-address-fields.tsx rename modules/customers/src/web/update/ui/editor/{customer-basic-info-fields.tsx => customer-basic-info-editor.tsx} (78%) delete mode 100644 modules/supplier-invoices/src/api/infrastucture/jobs/supplier-invoice-processing.job.ts rename modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/{sequelize-issued-invoice-domain.mapper.ts => sequelize-supplier-invoice-domain.mapper.ts} (83%) rename modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/{sequelize-issued-invoice-item-domain.mapper.ts => sequelize-supplier-invoice-item-domain.mapper.ts} (94%) rename modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/{sequelize-issued-invoice-recipient-domain.mapper.ts => sequelize-supplier-invoice-recipient-domain.mapper.ts} (92%) rename modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/{sequelize-issued-invoice-taxes-domain.mapper.ts => sequelize-supplier-invoice-taxes-domain.mapper.ts} (92%) delete mode 100644 modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts rename modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/{sequelize-issued-invoice-recipient-summary.mapper.ts => sequelize-supplier-invoice-recipient-summary.mapper.ts} (100%) rename modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/{sequelize-issued-invoice-summary.mapper.ts => sequelize-supplier-invoice-summary.mapper.ts} (99%) rename modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-section-card.tsx => packages/rdx-ui/src/components/form/form-section-card.tsx (89%) rename modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-header-form-grid.tsx => packages/rdx-ui/src/components/form/form-section-grid.tsx (81%) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 842e23e8..17248424 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -7,6 +7,7 @@ "formulahendry.auto-rename-tag", "cweijan.dbclient-jdbc", "pkief.material-icon-theme", - "fralle.copy-code-context" + "fralle.copy-code-context", + "imgildev.vscode-auto-barrel" ] } diff --git a/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts b/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts index 0818e233..504fc1d9 100644 --- a/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts +++ b/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts @@ -27,8 +27,8 @@ export const ICreateAccountRequestSchema = z.object({ default_tax: z.number(), status: z.string(), - language_code: z.string(), - currency_code: z.string(), + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, logo: z.string(), }); @@ -55,8 +55,8 @@ export const IUpdateAccountRequestSchema = z.object({ default_tax: z.number(), status: z.string(), - language_code: z.string(), - currency_code: z.string(), + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, logo: z.string(), }); diff --git a/docs/DTOS - GUIA DE ESTILO.md b/docs/DTOS - GUIA DE ESTILO.md deleted file mode 100644 index f2cfdcfe..00000000 --- a/docs/DTOS - GUIA DE ESTILO.md +++ /dev/null @@ -1,150 +0,0 @@ -# Guía de estilo — DTOs y organización de carpetas - -> **Objetivo:** asegurar que **todos** los equipos usen un vocabulario y una estructura de ficheros idéntica al modelar, versionar y serializar los *Data Transfer Objects* (DTO). -> Esta guía aplica a cualquier API HTTP/JSON implementada en Express + TypeScript que siga DDD, SOLID y CQRS. - ---- - -## 1. Estructura de carpetas obligatoria - -```text -src/ -└─ / (ej. billing/) - └─ api/ - └─ dto/ - ├─ common/ ← Tipos reutilizables (MoneyDTO, AddressDTO…) - ├─ request/ ← Sólo comandos y queries - │ ├─ *.command.dto.ts - │ └─ *.query.dto.ts - │ - └─ response/ ← Sólo resultados/vistas - ├─ *.result.dto.ts - └─ *.view.dto.ts - -``` - -*Alias TS recomendado*: `@/dto/*` → `src//api/dto/*`. - ---- - -## 2. Convención de nombres - -| Categoría | Sufijo **obligatorio** | Descripción & ejemplos | -|-----------|------------------------|------------------------| -| **Comandos** (mutaciones) | `…CommandDTO` | `CreateInvoiceCommandDTO`, `UpdateInvoiceCommandDTO`, `DeleteInvoiceCommandDTO`, `ChangeInvoiceStatusCommandDTO` | -| **Queries** (lecturas con filtros) | `…QueryDTO` | `ListInvoicesQueryDTO`, `GetInvoiceByIdQueryDTO` | -| **Resultados** (respuesta de comandos) | `…ResultDTO` | `InvoiceCreationResultDTO`, `InvoiceDeletionResultDTO` | -| **Vistas** (respuesta de queries) | `…ViewDTO` | `InvoiceViewDTO`, `InvoiceSummaryViewDTO` | -| **Tipos comunes** | `…DTO` | `MoneyDTO`, `PaginationMetaDTO`, `AddressDTO` | - -*Regla de oro:* **No existe ningún DTO sin sufijo, salvo los tipos comunes dentro de `common/`.** - ---- - -## 3. Reglas de contenido - -1. **Solo datos planos**: números, cadenas, literales, arrays; nada de lógica. -2. **Fechas** en ISO-8601 UTC (`yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`). -3. **Enums** expuestos como _string literal_ en `snake_case` o `UPPER_SNAKE_CASE`; evita números mágicos. -4. **Moneda** - - **Siempre** con la estructura común: - -```ts -export interface MoneyDTO { - amount: number | null; // unidades mínimas (ej. céntimos) - scale: number; // nº de decimales (2 = céntimos) - currency_code: string; // ISO-4217 (“EUR”) -} -``` - - - Se importa desde `dto/common/money.dto.ts`. - ---- - -## 4. Guía de mapeo - -| Dirección | Componente responsable | Ubicación | -|-----------|------------------------|-----------| -| **DTO → Dominio** | `…CommandMapper` / `…QueryMapper` | `src//application/mappers/` | -| **Dominio → DTO** | `…ResultMapper` / `…ViewMapper` | mismo directorio | - -Cada mapper implementa **una** función pública: - -```ts -interface InvoiceCreationResultMapper { - toResult(entity: Invoice): InvoiceCreationResultDTO; -} -``` - ---- - -## 5. Validación y versiones - -1. **Validación de entrada** - - Usa `class-validator` o Zod en el *controller*; nunca en el dominio. - - Convierte a Value Objects una vez que el DTO pasó la validación. - -2. **Versionado** - - Añade sufijos de versión en el **archivo**, no en el nombre de la interfaz: - `invoice.view.v2.dto.ts` → `export interface InvoiceViewV2DTO { … }` - - Mantén las versiones anteriores durante **≥1 release** o hasta que los consumidores migren. - ---- - -## 6. Ejemplo completo (creación de factura) - -```text -billing/ -├─ api/ -│ └─ dto/ -│ ├─ input/create-invoice.command.dto.ts -│ ├─ output/invoice-creation.result.dto.ts -│ └─ common/money.dto.ts -└─ application/ - └─ mappers/ - └─ invoice-creation.result.mapper.ts -``` - -```ts -// create-invoice.command.dto.ts -export interface CreateInvoiceCommandDTO { - customerId: string; - issueDate: string; - lines: ReadonlyArray<{ - description: string; - quantity: number; - unitPrice: MoneyDTO; - }>; -} - -// invoice-creation.result.dto.ts -export interface InvoiceCreationResultDTO { - invoiceId: string; - number: string; - totalAmount: MoneyDTO; - createdAt: string; -} -``` - ---- - -## 7. Checklist antes de hacer *merge* - -- [ ] Archivo ubicado en la carpeta correcta. -- [ ] Sufijo conforme (**CommandDTO**, **QueryDTO**, **ResultDTO**, **ViewDTO**). -- [ ] Todos los importes usan `MoneyDTO`. -- [ ] Tipos opcionales marcados con `?` y comentados. -- [ ] **Sin** lógica, constructores ni métodos. -- [ ] PR incluye al menos un test de mapper (input ⇄ dominio ⇄ output). - ---- - -## 8. Tabla resumen - -| Carpeta | Sufijo | Ejemplo clásico | -|---------|--------|-----------------| -| `dto/input/` | `CommandDTO` | `DeleteInvoiceCommandDTO` | -| `dto/input/` | `QueryDTO` | `ListInvoicesQueryDTO` | -| `dto/output/` | `ResultDTO` | `InvoiceDeletionResultDTO` | -| `dto/output/` | `ViewDTO` | `InvoiceSummaryViewDTO` | -| `dto/common/` | `DTO` | `MoneyDTO` | diff --git a/docs/DTOS - GUÍA DE DISEÑO.md b/docs/DTOS - GUÍA DE DISEÑO.md index 375657db..b470ca6a 100644 --- a/docs/DTOS - GUÍA DE DISEÑO.md +++ b/docs/DTOS - GUÍA DE DISEÑO.md @@ -1,49 +1,20 @@ -## Guía de diseño de DTOs para el ERP +# Guía de diseño de DTOs para el ERP ---- +## 1. Principios generales -* Un DTO debe representar **únicamente** el **contrato de transporte** entre procesos. -* DTOs request/response separados por endpoint, aunque internamente reutilice piezas. -* No reutilizar el mismo DTO para varios endpoints salvo que el contrato sea realmente idéntico y estable. -* Sin `defaults` en requests de negocio. Deben evitarse salvo casos muy concretos y no ambiguos. -* En la medida de lo posible, usar esquemas Zod v4 para luego inferir el tipo del DTO: - -Ejemplo: +- Un DTO representa únicamente el contrato de transporte entre cliente y servidor. +- Cada endpoint debe tener sus DTOs de `request` y `response`, aunque internamente reutilice subesquemas. +- No reutilizar el mismo DTO entre endpoints salvo que el contrato sea realmente idéntico y estable. +- Siempre que sea posible, definir DTOs a partir de esquemas Zod v4 e inferir el tipo desde el esquema. ```ts export type ListIssuedInvoicesRequestDTO = z.infer; ``` -## 1. Esquemas Zod y DTOs -* **El esquema valida, no “arregla” entradas**. Evitar `.default()`, `.transform()` y similares salvo excepción muy justificada. -* Las operaciones `list`, `get`, `create`, `update` y `report` no deben compartir el mismo esquema completo pero sí deben compartir `naming`, reglas de tipos, convenciones y reutilizar subesquemas comunes. -* No usar `z.string()` si se puede precisar más el tipo semántico. El schema debe expresar lo que **es** el dato, no solo cómo serializarlo. - -Ejemplos: - - booleano -> `z.boolean()` - - UUID -> `z.uuid()` - - estado cerrado -> `z.enum([...])` - - fecha ISO -> schema específico - - código ISO -> schema específico o restricción mínima consistente - -* Conservar el mismo tipo para el mismo campo en todos los esquemas. Si `customer_id` es UUID, debe ser UUID siempre. -* Conservar el mismo nombre para campos con la misma semántica a no ser que esté justificado. - -Ejeemplo. No alternar: - - `recipient_id` - - `customer_id` - - `recipient_customer_id` - ---- - -## 2. Estructura recomendada de DTOs - -## 2.1. Organización por módulo - -* Agrupar endpoints en `request/` y `response/` -* `shared/` para fragmentos reutilizables de shape -* no meter lógica de mapping en `dto/` +- El esquema valida; no debe “arreglar” entradas. Evitar `.default()`, `.transform()` y similares salvo excepción justificada. +- No mezclar DTOs con lógica de negocio, mapping ni estado de formulario. +## 2. Organización y naming ```plaintext common/dto/ @@ -67,138 +38,62 @@ common/dto/ recipient.dto.ts ``` ---- - -## 3. Convenciones de naming - -## 3.1. Ficheros - -* Siempre en `kebab-case`. -* Uso de singular/plural: colección -> plural (`proformas`), elemento -> singular (`proforma`) -* Patrón: +* Estructurar DTOs por módulo en `request/`, `response/` y `shared/`. +* `shared/` solo para subesquemas reutilizables y estables. +* Nombres de fichero en `kebab-case`. +* Patrón de nombres: ```plaintext -[...].request.dto.ts -[...].response.dto.ts ``` -Ejemplos: +* Usar singular/plural de forma consistente: -* `list-proformas.request.dto.ts` -* `get-proforma-by-id.response.dto.ts` -* `update-proforma-by-id.request.dto.ts` - ---- - -## 4. Reglas de tipos serializados - -## 4.1. Cuándo usar string - -Usar `string` cuando el dato de transporte sea realmente textual o cuando convenga preservar exactitud/neutralidad: - -* ids externos textuales -* códigos -* fechas ISO serializadas -* importes -* cantidades -* porcentajes -* referencias documentales -* descripciones -* notas - -## 4.2. Cuándo no usar string - -No serializar como string si el tipo semántico natural debe conservarse y no hay ventaja en degradarlo. + * colección -> plural + * elemento -> singular Ejemplos: -* booleanos -> `z.boolean()` -* enteros de paginación -> `z.number().int()` -* arrays -> `z.array(...)` -* objetos -> `z.object(...)` - - ---- - -## 5. Reglas para primitivas de negocio - -* Un VO monetario/cuantitativo siempre debe tener una sola forma de transporte en todo el ERP. - -## 5.1. Identificadores - -* Si el identificador es UUID, usar `z.uuid()`. -* Si existe un id no UUID, definir un schema específico con nombre explícito. -* No mezclar `z.string()` y `z.uuid()` para el mismo campo según endpoint. -* Un mismo identificador debe tener el mismo schema en todos los DTOs. - -Ejemplo: - -```ts -proforma_id: z.uuid() -customer_id: z.uuid() -company_id: z.uuid() +```plaintext +list-proformas.request.dto.ts +get-proforma-by-id.response.dto.ts +update-proforma-by-id.request.dto.ts ``` ---- +* `-summary.dto.ts` para vistas de resumen reutilizables de list. +* `-detail.dto.ts` para vistas de detalle reutilizables de get. +* `-.response.dto.ts` para la response completa del endpoint. +* `-summary.snapshot-builder.ts` para builders que materializan un item/resumen. +* Evitar nombres como `ISnapshot` si representan el mismo contrato que un DTO ya definido por schema. -## 5.2. Fechas y timestamps +## 3. Reglas de tipado -Distinguir entre: +* No usar `z.string()` si el tipo semántico del campo puede concretarse mejor. +* El mismo campo debe conservar siempre el mismo nombre y el mismo tipo en todos los DTOs. +* Ejemplos de tipado semántico: -* fecha de calendario sin hora -> "2020-01-01" -* timestamp con fecha y hora -> + * booleano -> `z.boolean()` + * UUID -> `z.uuid()` + * conjunto cerrado -> `z.enum([...])` + * fecha/timestamp ISO -> schema específico + * arrays -> `z.array(...)` + * objetos -> `z.object(...)` -```ts -export const IsoDateSchema = z.iso.date(); // "2020-01-01" -export const IsoTimeSchema = z.iso.time(); // "03:15:00", "03:15" -export const OffsetDateTimeSchema = z.iso.datetime({ offset: true }); // "2020-01-01T06:15:00+02:00" -export const LocalDateTimeSchema = z.iso.datetime({ local: true }); // "2020-01-01T06:15:01" -export const DateTimeSchema1 = z.iso.datetime({ precision: -1 }); // minute precision (no seconds) -> "2020-01-01T06:15Z" +## 4. Reglas para primitivas de negocio -``` +* Si un identificador es UUID, usar siempre `z.uuid()`. +* Si un identificador no es UUID, definir un schema específico con nombre explícito. +* Todo estado de negocio cerrado debe modelarse con `z.enum(...)`. +* No usar `z.string()` para booleanos ni para estados cerrados. +* Distinguir explícitamente entre fecha, hora y timestamp mediante schemas específicos. -Si `z.iso.date()` no está disponible o no os convence, crear schema propio. +## 5. Fechas, porcentajes, importes, cantidades ---- +### 5.1. Amount, Money, Percentage y Quantity -## 5.3. Enums y estados - -* Todo campo de conjunto cerrado debe ser `z.enum(...)`. Ejemplos: - -``` -* `status` -* `language_code` si el conjunto es acotado -* `currency_code` si estáis acotando monedas soportadas -``` - -* No usar `z.string()` para estados de negocio cerrados. -* Si el conjunto no está cerrado aún, al menos documentarlo y preparar schema específico. - -Ejemplo: - -```ts -export const ProformaStatusSchema = z.enum([ - "draft", - "pending", - "issued", - "cancelled", -]); -``` ---- - -## 5.4. Booleanos - -Ejemplo: - -```ts -z.boolean() -``` - ---- - -## 5.5. Amount, Money, Percentage y Quantity - -* Estos valores deben viajar como objetos explícitos: +* Los valores exactos no deben viajar como `number`. +* Deben viajar siempre con una única forma de transporte para toda la API. ```ts type MoneyDTO = { @@ -223,96 +118,81 @@ type QuantityDTO = { } ``` -## 5.6. Listas +### 5.2. Fechas y timestamps +Distinguir entre: + +* fecha de calendario sin hora -> "2020-01-01" +* timestamp con fecha y hora -> ```ts -type ListIssuedInvoicesResponseDTO = { - page: number, - per_page: number, - total_pages: number, - total_items: number, - items: { - ... - } -} +export const IsoDateSchema = z.iso.date(); // "2020-01-01" +export const IsoTimeSchema = z.iso.time(); // "03:15:00", "03:15" +export const OffsetDateTimeSchema = z.iso.datetime({ offset: true }); // "2020-01-01T06:15:00+02:00" +export const LocalDateTimeSchema = z.iso.datetime({ local: true }); // "2020-01-01T06:15:01" +export const DateTimeSchema1 = z.iso.datetime({ precision: -1 }); // minute precision (no seconds) -> "2020-01-01T06:15Z" + ``` ---- +Si se prefiere no usar `z.iso.date()`, crear schema propio. -## 6. Nullabilidad, opcionalidad y semántica de ausencia -Significado: +## 6. Opcionalidad, nullabilidad y ausencia - - `undefined` / campo omitido: no enviado - - `null`: enviado explícitamente sin valor - - `""`: cadena vacía, no ausencia - - `[]`: colección vacía, no ausencia +Significados: -**No cambiar estos significados salvo decisión explícita de contrato.** +* `undefined` / campo omitido -> no enviado +* `null` -> enviado explícitamente sin valor +* `""` -> cadena vacía, no indica ausencia de ese campo +* `[]` -> colección vacía, no indica ausencia de esa colección -### 6.1. Para `requests` +No cambiar estos significados salvo decisión explícita de contrato. -* Preferir `optional()` para campos no obligatorios. -* Usar `nullable()` solo cuando tenga significado de negocio “borrar/desvincular”. +### 6.1. Para requests + +* Usar `optional()` para campos no obligatorios. +* Usar `nullable()` solo cuando tenga semántica de negocio, por ejemplo “borrar” o “desvincular”. Ejemplo: ```ts payment_method_id: z.uuid().nullable().optional() +``` Interpretación: * omitido -> no tocar -* `null` -> quitar método de pago -* UUID -> asignar id de método de pago +* `null` -> quitar relación +* UUID -> asignar relación + +### 6.2. Para responses + +* Decidir de forma explícita si una ausencia semántica se representa con `null` o con omisión. +* Para relaciones opcionales, suele ser más claro usar `null` que omitir la relación. + +## 7. Reglas para CREATE + +* El request de `create` debe contener solo los datos necesarios para crear el recurso. El resto, como opcionales => `optional()` +* No reutilizar el DTO de detalle como DTO de creación. +* No usar defaults en requests de negocio. +* Si hace falta normalización, hacerla fuera del DTO: + +```plaintext +validación DTO => normalización input => mapeo a command/props ``` -### 6.3. Para `responses` +## 8. Reglas para UPDATE -* si el campo puede no existir semánticamente, decidir si se usa `null` o ausencia `""` -* para relaciones opcionales, suele ser más claro usar `null` que omitir la relación. +* `update` debe seguir semántica tipo `PATCH`: ---- - -## 7. Reglas para requests de CREATE - -* Definir como obligatorios en el esquema los campos necesarios para crear el recurso. El resto, como opcionales => `optional()` -* No reutilizar el DTO de detalle como DTO de creación. -* No usar defaults + * solo viajan los campos a modificar + * los campos omitidos no se tocan +* La opcionalidad debe declararse en el esquema con `optional()`. +* No usar `Partial<>` sobre tipos inferidos si el esquema ya expresa opcionalidad. +* No usar defaults en `update`, especialmente en colecciones. Incorrecto: -```ts -description: z.string().default("") -``` - -Correcto: - -```ts -description: z.string().optional() -``` - -* Si el backend necesita normalización, hacerla fuera del DTO. Secuencia: `validación DTO` => `normalización input` => ' ' - ---- - -## 8. Reglas para requests de UPDATE - -* Usar semántica tipo **PATCH**: - - solo viajan los campos a modificar - - los campos omitidos no se tocan - -* Si un campo es opcional, usar `optional()` en el esquema: - -```ts -series: z.string().optional() -``` - -* No usar `defaults` en update: - -Esto es incorrecto porque `[]` puede significar “vaciar items”, no “campo omitido”. - ```ts items: z.array(...).default([]) ``` @@ -323,239 +203,210 @@ Correcto: items: z.array(...).optional() ``` -* no usar `Partial<>`: +### 8.1. UPDATE de colecciones -Incorrecto: - -```ts -type UpdateProformaByIdRequestDTO = Partial>; -``` - - -## 8.1. Reglas para request de UPDATE en **colecciones** - -* En `update` de colecciones, mantener reemplazo completo de la colección -* Si se necesitara granularidad real en el `update` de items, valorar usar en la API una estructura explícita: +* Por defecto, mantener reemplazo completo de la colección. +* Si se necesita granularidad real, usar una estructura explícita: ```ts items: { - create: [...] - update: [...] - delete: [...] + create: [...], + update: [...], + delete: [...], +} +``` + +## 9. Responses de LIST y DETAIL + +* `list` y `get` no deben compartir el mismo esquema completo. +* Separar vistas de resumen y detalle: + + * `List` -> summary + * `Get` -> detail +* `list` debe contener solo lo necesario para el listado. +* Puede existir un esquema base compartido solo si mejora claridad y estabilidad. + +## 10. Subesquemas reutilizables + +* Extraer a `shared/` toda estructura no trivial que se repita y sea estable. +* No extraer abstracciones prematuramente. + +Ejemplos típicos: + +* `recipient` +* `item` +* `tax-breakdown` +* `payment-method-ref` + +## 10.bis. Snapshot builders y materialización de DTOs + +* Los snapshot builders de API existen para materializar objetos de salida serializables a partir de modelos de aplicación, agregados o read models. +* Un snapshot builder **no define el contrato HTTP**; el contrato lo define el schema DTO. +* El schema Zod es la **única fuente de verdad (SSOT)** del contrato de salida. + +--- + +### Regla principal + +> Si un snapshot builder genera un DTO de API, su tipo de salida debe ser el `z.infer` del schema exacto que representa. + +Ejemplo: + +```ts +export const CustomerSummarySchema = z.object({ ... }); +export type CustomerSummaryDTO = z.infer; + +export class CustomerSummarySnapshotBuilder + implements ISnapshotBuilder { + public toOutput(model: CustomerSummary): CustomerSummaryDTO { + ... + } } ``` --- -## 11. Reglas para responses de **LIST** y **DETAIL** +### Reglas obligatorias -* Separar summary y detail: - - List => summary => `ProformaSummaryDTO` - - Get <=> detail => `ProformaDetailDTO` +* No duplicar el contrato mediante interfaces manuales paralelas si ya existe un schema Zod equivalente. +* Eliminar tipos como `ICustomerSummarySnapshot` si representan el mismo contrato que un DTO. +* Si el builder construye una subvista reutilizable, debe tiparse contra el subschema compartido correspondiente (`shared/`). +* Si el builder construye la response completa, debe tiparse contra el response DTO completo. +* El builder debe devolver exactamente la forma del schema (no “aproximada”). -* `list` debe contener solo lo imprescindible para la tabla/listado -* Puede definirse un esquema base compartido **si aporta claridad**. Ejemplo: +--- + +### Responsabilidades del snapshot builder + +Puede: + +* Mapear `Maybe` → `null` +* Convertir Value Objects → primitives o DTOs (`string`, `{ value, scale }`, etc.) +* Adaptar enums internos → valores de contrato +* Construir vistas (`summary`, `detail`, etc.) + +No puede: + +* Definir contratos +* Validar reglas de negocio +* Acceder a repositorios +* Ejecutar lógica de dominio +* “corregir” datos fuera del contrato definido + +--- + +### Separación por niveles + +Si el endpoint es: ```ts -const ProformaBaseDTOSchema = z.object({ - id: z.uuid(), - company_id: z.uuid(), - customer_id: z.uuid(), - invoice_number: z.string(), - status: ProformaStatusSchema, - series: z.string(), - invoice_date: IsoDateSchema, - operation_date: IsoDateSchema, - language_code: z.string(), - currency_code: z.string(), - reference: z.string().nullable(), - description: z.string().nullable(), -}); +ListCustomersResponseSchema ``` -Despues: +Entonces: -* `summary` extiende añadiendo algún campos más -* `detail` extiende añadiendo taxes, items, notes, etc. +* Subschema: + +```ts +CustomerSummarySchema +``` + +* Builder: + +```ts +CustomerSummarySnapshotBuilder -> CustomerSummaryDTO +``` + +No: + +```ts +Builder -> ListCustomersResponseDTO ❌ +``` --- -## 12. Reglas para subesquemas reutilizables +### Naming obligatorio -* Estructura no trivial que se repite => definir subesquema en `shared/`. -* Deben ser estructuras estables no sujetas a cambios. En caso contrario, mejor no reutilizar +DTO: -Ejemplos claros en tu caso: +```plaintext +-summary.dto.ts +-detail.dto.ts +-.response.dto.ts +``` -* `recipient` -* `proforma-item` -* `proforma-tax-breakdown` -* `payment-method-ref` +Builders: + +```plaintext +-summary.snapshot-builder.ts +-detail.snapshot-builder.ts +-.response.snapshot-builder.ts +``` + +Evitar: + +```plaintext +ISnapshot ❌ +Output ❌ +Interface ❌ +``` --- -## 13. Reglas específicas para errores +### Regla de consistencia + +> Si cambia el schema DTO, el builder debe romper en compile-time. + +Esto garantiza: + +* contrato consistente +* ausencia de drift +* eliminación de duplicación + +--- + +## 11. Errores * El DTO de error debe ser estable, tipado y predecible. +* No usar shapes excesivamente genéricos si ya se conoce la estructura mínima útil. +* El cliente debe poder representar errores sin heurísticas frágiles. -## 13.1. Estructura base recomendada +## 12. Metadata -```ts -export const ErrorDetailDTOSchema = z.object({ - code: z.string(), - path: z.string().optional(), - message: z.string(), -}); +* `metadata` solo debe contener datos auxiliares, trazabilidad no crítica, versionado o hints no funcionales. +* Si un dato es necesario para que el cliente funcione, no debe ir en `metadata`, sino en el DTO principal. -export const ErrorResponseDTOSchema = z.object({ - type: z.string().optional(), - title: z.string(), - status: z.number().int(), - detail: z.string().optional(), - instance: z.string().optional(), - context: z - .object({ - params: z.record(z.string(), z.unknown()).optional(), - query: z.record(z.string(), z.unknown()).optional(), - body: z.record(z.string(), z.unknown()).optional(), - }) - .optional(), - extra: z - .object({ - errors: z.array(ErrorDetailDTOSchema).default([]), - }) - .optional(), -}); -``` +## 13. Evolución de la API -## 13.2. Regla +* Es mejor añadir campos que reinterpretar campos existentes. +* Cambiar el significado o la estructura de un campo implica cambio de contrato. -No dejar `errors: Record[]` si ya conoces la forma mínima útil. +## 14. Criterio para detectar overengineering -El cliente necesita poder renderizar errores sin heurísticas frágiles. +Un diseño de DTO está sobreingenierizado si: ---- - -## 14. Reglas de metadata - -* Si un consumidor necesita ese dato para funcionar, no va en `metadata`: va en el DTO principal. - -## 14.1. Cuándo usarla - -Solo para: - -* datos auxiliares -* trazabilidad no crítica -* versionado -* hints no funcionales - -## 14.2. Cuándo no usarla - -No usar `metadata` para: - -* reglas de negocio -* campos que condicionan UI principal -* datos que el cliente necesita interpretar de forma contractual - - ---- - -## 15. Evolución de la API - -* Es mejor añadir un campo nuevo que cambiar el significado de uno existente. -* Si un campo cambia de semántica o estructura, debe tratarse como cambio de contrato. - - -## 16. Criterio para detectar `overengineering` - -Considera que un diseño de DTO está `sobreingenierizado` si ocurre alguna de estas: - -* necesitas abrir 5 ficheros para entender un endpoint simple -* el schema hace validación, normalización, defaults y transformación a la vez -* hacer abstracciones compartidas en `shared` que lueo solo se utilizan en un caso +* para entender un endpoint simple hay que abrir demasiados ficheros +* el esquema valida, normaliza y transforma a la vez +* se extraen subesquemas que apenas se reutilizan * el DTO intenta reflejar demasiado fielmente el dominio interno + +Regla práctica: + * preferir duplicación pequeña y clara frente a abstracción prematura ---- +## 15. Checklist de revisión -## 21. Plantilla base recomendada +Antes de cerrar un DTO, comprobar: -## 21.1. Request simple por id - -```ts -import { z } from "zod/v4"; - -export const GetProformaByIdRequestSchema = z.object({ - proforma_id: z.uuid(), -}); - -export type GetProformaByIdRequestDTO = z.infer; -``` - -## 21.2. Fragmento shared - -```ts -import { z } from "zod/v4"; -import { MoneyDTOSchema, PercentageDTOSchema } from "@erp/core"; - -export const ProformaTaxDTOSchema = z.object({ - taxable_amount: MoneyDTOSchema, - - iva_code: z.string(), - iva_percentage: PercentageDTOSchema, - iva_amount: MoneyDTOSchema, - - rec_code: z.string(), - rec_percentage: PercentageDTOSchema, - rec_amount: MoneyDTOSchema, - - retention_code: z.string(), - retention_percentage: PercentageDTOSchema, - retention_amount: MoneyDTOSchema, - - taxes_amount: MoneyDTOSchema, -}); - -export type ProformaTaxDTO = z.infer; -``` - -## 21.3. Update parcial sin defaults - -```ts -import { z } from "zod/v4"; - -export const UpdateProformaByIdRequestSchema = z.object({ - series: z.string().optional(), - invoice_date: z.iso.date().optional(), - operation_date: z.iso.date().optional(), - customer_id: z.uuid().optional(), - reference: z.string().optional(), - description: z.string().optional(), - notes: z.string().optional(), - language_code: z.string().optional(), - currency_code: z.string().optional(), - items: z.array(UpdateProformaItemRequestSchema).optional(), -}); - -export type UpdateProformaByIdRequestDTO = z.infer; -``` - ---- - -## 22. Checklist de revisión de un DTO - -Antes de dar un DTO por bueno, comprobar: - -1. ¿Representa contrato de transporte y nada más? -2. ¿Cada campo tiene el tipo semántico más preciso posible? -3. ¿Se distingue bien entre omitido, null, vacío y colección vacía? -4. ¿Hay defaults que estén ocultando intención? -5. ¿El naming es consistente con el resto? -6. ¿Hay subestructuras repetidas que merezcan extraerse? -7. ¿Hay abstracciones extraídas demasiado pronto? +1. ¿Representa solo contrato de transporte? +2. ¿Cada campo usa el tipo semántico más preciso posible? +3. ¿Se distingue bien entre omitido, `null`, vacío y colección vacía? +4. ¿Hay defaults ocultando intención? +5. ¿El naming es consistente? +6. ¿Las estructuras repetidas merecen extraerse? +7. ¿Hay abstracciones prematuras? 8. ¿El cliente puede consumirlo sin heurísticas frágiles? -9. ¿El mismo campo mantiene mismo tipo en todo el módulo? -10. ¿El DTO refleja una vista del endpoint y no una mezcla de varias? - ---- +9. ¿El mismo campo mantiene el mismo tipo en todo el módulo? +10. ¿El DTO refleja una vista concreta del endpoint? +--- \ No newline at end of file diff --git a/modules/core/src/api/infrastructure/express/api-error-mapper.ts b/modules/core/src/api/infrastructure/express/api-error-mapper.ts index 20e82df8..9b3e9596 100644 --- a/modules/core/src/api/infrastructure/express/api-error-mapper.ts +++ b/modules/core/src/api/infrastructure/express/api-error-mapper.ts @@ -17,6 +17,7 @@ import { isDomainValidationError, isValidationErrorCollection, } from "@repo/rdx-ddd"; +import type { ZodError } from "zod"; import { isSchemaError } from "../../../common/schemas"; import { type DocumentGenerationError, isDocumentGenerationError } from "../../application"; @@ -33,7 +34,6 @@ import { isInfrastructureRepositoryError, isInfrastructureUnavailableError, } from "../errors"; -import type { InfrastructureAPIContractError } from "../errors/infrastructure-api-contract-error"; import { ApiError, @@ -214,8 +214,10 @@ const defaultRules: ReadonlyArray = [ { priority: 30, matches: (e) => isSchemaError(e), - build: (e) => - new InternalApiError((e as InfrastructureAPIContractError).message, "API contract violation"), + build: (e) => { + const schemaError = e as ZodError; + return new ValidationApiError("Schema validation failed", schemaError.issues); + }, }, ]; diff --git a/modules/core/src/common/dto/amount-money.dto.ts b/modules/core/src/common/dto/amount-money.dto.ts index e914a700..182e1f3f 100644 --- a/modules/core/src/common/dto/amount-money.dto.ts +++ b/modules/core/src/common/dto/amount-money.dto.ts @@ -1,6 +1,7 @@ -import { z } from "zod/v4"; +import type { z } from "zod/v4"; import { AmountBaseSchema } from "./base.schemas"; +import { CurrencyCodeSchema } from "./currency-code.dto"; /** Esquema del DTO para valores monetarios con/sin código de moneda. @@ -13,7 +14,7 @@ import { AmountBaseSchema } from "./base.schemas"; // 🔹 Con moneda export const MoneySchema = AmountBaseSchema.extend({ - currency_code: z.string(), + currency_code: CurrencyCodeSchema, }); // 🔹 Sin moneda diff --git a/modules/core/src/common/dto/country-code.dto.ts b/modules/core/src/common/dto/country-code.dto.ts new file mode 100644 index 00000000..03a9fac6 --- /dev/null +++ b/modules/core/src/common/dto/country-code.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v4"; + +/** + * Código de país ISO 3166-1 alpha-2 en mayúsculas. + * + * Valida exactamente 2 letras ASCII mayúsculas. + * Ejemplos válidos: "ES", "FR", "PT". + */ +export const CountryCodeSchema = z + .string() + .regex(/^[A-Z]{2}$/, "Country code must be a valid ISO 3166-1 alpha-2 uppercase code."); + +export type CountryCodeDTO = z.infer; diff --git a/modules/core/src/common/dto/currency-code.dto.ts b/modules/core/src/common/dto/currency-code.dto.ts new file mode 100644 index 00000000..4ccf7d2e --- /dev/null +++ b/modules/core/src/common/dto/currency-code.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v4"; + +/** + * Código de moneda ISO 4217 (alpha-3). + * + * Valida exactamente 3 letras ASCII mayúsculas. + * Ejemplos válidos: "EUR", "USD", "GBP". + */ +export const CurrencyCodeSchema = z + .string() + .regex(/^[A-Z]{3}$/, "Currency code must be a valid ISO 4217 alpha-3 uppercase code."); + +export type CurrencyCodeDTO = z.infer; diff --git a/modules/core/src/common/dto/email.dto.ts b/modules/core/src/common/dto/email.dto.ts new file mode 100644 index 00000000..04d68ea9 --- /dev/null +++ b/modules/core/src/common/dto/email.dto.ts @@ -0,0 +1,5 @@ +import { z } from "zod/v4"; + +export const EmailSchema = z.string().email(); + +export type EmailDTO = z.infer; diff --git a/modules/core/src/common/dto/index.ts b/modules/core/src/common/dto/index.ts index 4fc6506b..453326af 100644 --- a/modules/core/src/common/dto/index.ts +++ b/modules/core/src/common/dto/index.ts @@ -1,8 +1,18 @@ export * from "./amount-money.dto"; export * from "./base.schemas"; +export * from "./country-code.dto"; export * from "./critera.dto"; +export * from "./currency-code.dto"; +export * from "./email.dto"; export * from "./error.dto"; +export * from "./iso-date-dto"; +export * from "./land-phone.dto"; +export * from "./language-code.dto"; export * from "./list-view.response.dto"; export * from "./metadata.dto"; +export * from "./mobile-phone.dto"; export * from "./percentage.dto"; +export * from "./postal-code.dto"; export * from "./quantity.dto"; +export * from "./tin.dto"; +export * from "./url.dto"; diff --git a/modules/core/src/common/dto/iso-date-dto.ts b/modules/core/src/common/dto/iso-date-dto.ts new file mode 100644 index 00000000..b4748724 --- /dev/null +++ b/modules/core/src/common/dto/iso-date-dto.ts @@ -0,0 +1,51 @@ +import { z } from "zod/v4"; + +/** + * Esquema para validar una fecha en formato ISO 8601 (solo fecha). + * + * @remarks + * Formato esperado: `YYYY-MM-DD` + * + * @example + * "2020-01-01" + */ +export const IsoDateSchema = z.iso.date(); + +/** + * Esquema para validar una hora en formato ISO 8601. + * + * @remarks + * Acepta horas con o sin segundos. + * Formatos válidos: + * - `HH:mm` + * - `HH:mm:ss` + * + * @example + * "03:15" + * "03:15:00" + */ +export const IsoTimeSchema = z.iso.time(); + +/** + * Esquema para validar una fecha y hora con offset de zona horaria. + * + * @remarks + * Requiere un desplazamiento explícito respecto a UTC. + * Formato: `YYYY-MM-DDTHH:mm:ss±HH:mm` + * + * @example + * "2020-01-01T06:15:00+02:00" + */ +export const OffsetDateTimeSchema = z.iso.datetime({ offset: true }); + +/** + * Esquema para validar una fecha y hora local (sin información de zona horaria). + * + * @remarks + * No incluye offset ni información de zona. + * Formato: `YYYY-MM-DDTHH:mm:ss` + * + * @example + * "2020-01-01T06:15:01" + */ +export const LocalDateTimeSchema = z.iso.datetime({ local: true }); diff --git a/modules/core/src/common/dto/land-phone.dto.ts b/modules/core/src/common/dto/land-phone.dto.ts new file mode 100644 index 00000000..a28a7be6 --- /dev/null +++ b/modules/core/src/common/dto/land-phone.dto.ts @@ -0,0 +1,5 @@ +import { z } from "zod/v4"; + +export const LandPhoneSchema = z.string().regex(/^\d{3}-\d{3}-\d{4}$/); + +export type LandPhoneDTO = z.infer; diff --git a/modules/core/src/common/dto/language-code.dto.ts b/modules/core/src/common/dto/language-code.dto.ts new file mode 100644 index 00000000..9ae4b158 --- /dev/null +++ b/modules/core/src/common/dto/language-code.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v4"; + +/** + * Código de idioma ISO 639-1. + * + * Valida exactamente 2 letras ASCII minúsculas. + * Ejemplos válidos: "es", "en", "fr". + */ +export const LanguageCodeSchema = z + .string() + .regex(/^[a-z]{2}$/, "Language code must be a valid ISO 639-1 lowercase code."); + +export type LanguageCodeDTO = z.infer; diff --git a/modules/core/src/common/dto/mobile-phone.dto.ts b/modules/core/src/common/dto/mobile-phone.dto.ts new file mode 100644 index 00000000..c64c0c27 --- /dev/null +++ b/modules/core/src/common/dto/mobile-phone.dto.ts @@ -0,0 +1,5 @@ +import { z } from "zod/v4"; + +export const MobilePhoneSchema = z.string().regex(/^\d{3}-\d{3}-\d{4}$/); + +export type MobilePhoneDTO = z.infer; diff --git a/modules/core/src/common/dto/postal-code.dto.ts b/modules/core/src/common/dto/postal-code.dto.ts new file mode 100644 index 00000000..df0780c4 --- /dev/null +++ b/modules/core/src/common/dto/postal-code.dto.ts @@ -0,0 +1,21 @@ +import { z } from "zod/v4"; + +/** + * Código postal genérico (global). + * + * Permite letras, números, espacios y guiones. + * No valida formato específico por país. + * + * Ejemplos válidos: + * - "28013" (ES) + * - "SW1A 1AA" (UK) + * - "75008" (FR) + * - "10115" (DE) + */ +export const PostalCodeSchema = z + .string() + .min(1, "Postal code cannot be empty") + .max(16, "Postal code too long") + .regex(/^[A-Za-z0-9\- ]+$/, "Invalid postal code format"); + +export type PostalCodeDTO = z.infer; diff --git a/modules/core/src/common/dto/tin.dto.ts b/modules/core/src/common/dto/tin.dto.ts new file mode 100644 index 00000000..d3fae216 --- /dev/null +++ b/modules/core/src/common/dto/tin.dto.ts @@ -0,0 +1,23 @@ +import { z } from "zod/v4"; + +/** + * Tax Identification Number (TIN) genérico internacional. + * + * Permite letras y números ASCII, además de separadores frecuentes. + * No valida reglas específicas por país. + * + * Ejemplos válidos: + * - "B83999441" + * - "ESB83999441" + * - "48086V" + * - "FR40303265045" + * - "DE123456789" + */ +export const TinSchema = z + .string() + .trim() + .min(1, "TIN cannot be empty.") + .max(32, "TIN is too long.") + .regex(/^(?=.*[A-Za-z0-9])[A-Za-z0-9.\-/ ]+$/, "TIN has an invalid format."); + +export type TinDTO = z.infer; diff --git a/modules/core/src/common/dto/url.dto.ts b/modules/core/src/common/dto/url.dto.ts new file mode 100644 index 00000000..af656862 --- /dev/null +++ b/modules/core/src/common/dto/url.dto.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const URLSchema = z.url({ + message: "Invalid URL format", +}); + +export type URLDTO = z.infer; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/models/issued-invoice-summary.ts b/modules/customer-invoices/src/api/application/issued-invoices/models/issued-invoice-summary.ts index fea0d4c8..17b9174f 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/models/issued-invoice-summary.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/models/issued-invoice-summary.ts @@ -38,4 +38,6 @@ export type IssuedInvoiceSummary = { totalAmount: InvoiceAmount; verifactu: Maybe; + + linkedProformaId: Maybe; }; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts index a329522c..db13b6c9 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts @@ -1,5 +1,5 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { maybeToEmptyString } from "@repo/rdx-ddd"; +import { maybeToNullable } from "@repo/rdx-ddd"; import type { IssuedInvoice } from "../../../../domain"; @@ -26,31 +26,26 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps const verifactu = this.verifactuBuilder.toOutput(invoice); const taxes = this.taxesBuilder.toOutput(invoice.taxes); - const payment = invoice.paymentMethod.match( - (payment) => { - const { id, payment_description } = payment.toObjectString(); - return { - payment_id: id, - payment_description, - }; - }, - () => undefined - ); + const payment_method = maybeToNullable(invoice.paymentMethod, (p) => ({ + payment_id: p.id.toString(), + payment_description: p.paymentDescription.toString(), + })); return { id: invoice.id.toString(), company_id: invoice.companyId.toString(), + is_proforma: false, invoice_number: invoice.invoiceNumber.toString(), status: invoice.status.toPrimitive(), - series: maybeToEmptyString(invoice.series, (value) => value.toString()), + series: invoice.series.toString(), invoice_date: invoice.invoiceDate.toDateString(), - operation_date: maybeToEmptyString(invoice.operationDate, (value) => value.toDateString()), + operation_date: maybeToNullable(invoice.operationDate, (value) => value.toDateString()), - reference: maybeToEmptyString(invoice.reference, (value) => value.toString()), - description: maybeToEmptyString(invoice.description, (value) => value.toString()), - notes: maybeToEmptyString(invoice.notes, (value) => value.toString()), + reference: maybeToNullable(invoice.reference, (value) => value.toString()), + description: invoice.description.toString(), + notes: maybeToNullable(invoice.notes, (value) => value.toString()), language_code: invoice.languageCode.toString(), currency_code: invoice.currencyCode.toString(), @@ -58,14 +53,19 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps customer_id: invoice.customerId.toString(), recipient, - payment_method: payment, + linked_proforma_id: maybeToNullable(invoice.linkedProformaId, (linkedId) => + linkedId.toString() + ), + + taxes, + + payment_method, subtotal_amount: invoice.subtotalAmount.toObjectString(), - items_discount_amount: invoice.itemsDiscountAmount.toObjectString(), + items_discount_amount: invoice.itemsDiscountAmount.toObjectString(), global_discount_percentage: invoice.globalDiscountPercentage.toObjectString(), global_discount_amount: invoice.globalDiscountAmount.toObjectString(), - total_discount_amount: invoice.totalDiscountAmount.toObjectString(), taxable_amount: invoice.taxableAmount.toObjectString(), @@ -77,8 +77,6 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps taxes_amount: invoice.taxesAmount.toObjectString(), total_amount: invoice.totalAmount.toObjectString(), - taxes, - verifactu, items, diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts index 72d3706c..0b6ba1b4 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts @@ -3,20 +3,32 @@ import type { IIssuedInvoiceRecipientFullSnapshot } from "./issued-invoice-recip import type { IIssuedInvoiceTaxFullSnapshot } from "./issued-invoice-tax-full-snapshot-interface"; import type { IIssuedInvoiceVerifactuFullSnapshot } from "./issued-invoice-verifactu-full-snapshot.interface"; +/** + * Utilizar GetIssuedInvoiceByIdResponseDTO o + * definir un tipo específico para el snapshot de factura emitida, + * ya que GetIssuedInvoiceByIdResponseDTO tiene campos que no se + * corresponden exactamente con el snapshot + * (por ejemplo, tiene un campo "linked_invoice" que en el snapshot se representa + * como "linked_invoice_id"). + */ + +//export type IIssuedInvoiceFullSnapshot = GetIssuedInvoiceByIdResponseDTO; + export interface IIssuedInvoiceFullSnapshot { id: string; company_id: string; + is_proforma: boolean; invoice_number: string; status: string; series: string; invoice_date: string; - operation_date: string; + operation_date: string | null; - reference: string; + reference: string | null; description: string; - notes: string; + notes: string | null; language_code: string; currency_code: string; @@ -24,18 +36,20 @@ export interface IIssuedInvoiceFullSnapshot { customer_id: string; recipient: IIssuedInvoiceRecipientFullSnapshot; - payment_method?: { + linked_proforma_id: string | null; + + taxes: IIssuedInvoiceTaxFullSnapshot[]; + + payment_method: { payment_id: string; payment_description: string; - }; + } | null; subtotal_amount: { value: string; scale: string; currency_code: string }; items_discount_amount: { value: string; scale: string; currency_code: string }; - global_discount_percentage: { value: string; scale: string }; global_discount_amount: { value: string; scale: string; currency_code: string }; - total_discount_amount: { value: string; scale: string; currency_code: string }; taxable_amount: { value: string; scale: string; currency_code: string }; @@ -47,8 +61,6 @@ export interface IIssuedInvoiceFullSnapshot { taxes_amount: { value: string; scale: string; currency_code: string }; total_amount: { value: string; scale: string; currency_code: string }; - taxes: IIssuedInvoiceTaxFullSnapshot[]; - verifactu: IIssuedInvoiceVerifactuFullSnapshot; items: IIssuedInvoiceItemFullSnapshot[]; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-item-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-item-full-snapshot.interface.ts index 5570d064..0adf5378 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-item-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-item-full-snapshot.interface.ts @@ -1,8 +1,8 @@ export interface IIssuedInvoiceItemFullSnapshot { id: string; - is_valued: string; - position: string; - description: string; + is_valued: boolean; + position: number; + description: string | null; quantity: { value: string; scale: string }; unit_amount: { value: string; scale: string; currency_code: string }; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts index 5b849d3b..497bae7e 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts @@ -4,6 +4,7 @@ import { maybeToEmptyPercentageObjectString, maybeToEmptyQuantityObjectString, maybeToEmptyString, + maybeToNullable, } from "@repo/rdx-ddd"; import { type IssuedInvoiceItem, type IssuedInvoiceItems, ItemAmount } from "../../../../domain"; @@ -21,10 +22,10 @@ export class IssuedInvoiceItemsFullSnapshotBuilder return { id: invoiceItem.id.toPrimitive(), - is_valued: String(isValued), - position: String(index), + is_valued: isValued, + position: index, - description: maybeToEmptyString(invoiceItem.description, (value) => value.toString()), + description: maybeToNullable(invoiceItem.description, (value) => value.toString()), quantity: maybeToEmptyQuantityObjectString(invoiceItem.quantity), unit_amount: maybeToEmptyMoneyObjectString(invoiceItem.unitAmount), diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-recipient-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-recipient-full-snapshot-builder.ts index d77f2583..3f2ec0e1 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-recipient-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-recipient-full-snapshot-builder.ts @@ -1,7 +1,7 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { DomainValidationError, maybeToEmptyString } from "@repo/rdx-ddd"; +import { DomainValidationError, maybeToNullable } from "@repo/rdx-ddd"; -import type { InvoiceRecipient, IssuedInvoice } from "../../../../domain"; +import type { IssuedInvoice } from "../../../../domain"; import type { IIssuedInvoiceRecipientFullSnapshot } from "./issued-invoice-recipient-full-snapshot.interface"; @@ -17,30 +17,18 @@ export class IssuedInvoiceRecipientFullSnapshotBuilder cause: invoice, }); } + const recipient = invoice.recipient; - return invoice.recipient.match( - (recipient: InvoiceRecipient) => ({ - id: invoice.customerId.toString(), - name: recipient.name.toString(), - tin: recipient.tin.toString(), - street: maybeToEmptyString(recipient.street, (v) => v.toString()), - street2: maybeToEmptyString(recipient.street2, (v) => v.toString()), - city: maybeToEmptyString(recipient.city, (v) => v.toString()), - province: maybeToEmptyString(recipient.province, (v) => v.toString()), - postal_code: maybeToEmptyString(recipient.postalCode, (v) => v.toString()), - country: maybeToEmptyString(recipient.country, (v) => v.toString()), - }), - () => ({ - id: "", - name: "", - tin: "", - street: "", - street2: "", - city: "", - province: "", - postal_code: "", - country: "", - }) - ); + return { + id: invoice.customerId.toString(), + name: recipient.name.toString(), + tin: recipient.tin.toString(), + street: maybeToNullable(recipient.street, (v) => v.toString()), + street2: maybeToNullable(recipient.street2, (v) => v.toString()), + city: maybeToNullable(recipient.city, (v) => v.toString()), + province: maybeToNullable(recipient.province, (v) => v.toString()), + postal_code: maybeToNullable(recipient.postalCode, (v) => v.toString()), + country: maybeToNullable(recipient.country, (v) => v.toString()), + }; } } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-recipient-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-recipient-full-snapshot.interface.ts index c56cbb72..e46c244c 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-recipient-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-recipient-full-snapshot.interface.ts @@ -1,11 +1,19 @@ -export interface IIssuedInvoiceRecipientFullSnapshot { - id: string; - name: string; - tin: string; - street: string; - street2: string; - city: string; - province: string; - postal_code: string; - country: string; +/** + * Fijarse en IssuedInvoiceRecipientSummarySchema + */ + +import type { IssuedInvoiceRecipientSummaryDTO } from "../../../../../common/dto"; + +export type IIssuedInvoiceRecipientFullSnapshot = IssuedInvoiceRecipientSummaryDTO; + +interface IIssuedInvoiceRecipientFullSnapshot2 { + id: string | null; + name: string | null; + tin: string | null; + street: string | null; + street2: string | null; + city: string | null; + province: string | null; + postal_code: string | null; + country: string | null; } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-tax-full-snapshot-interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-tax-full-snapshot-interface.ts index b93c5ed3..bcde770f 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-tax-full-snapshot-interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-tax-full-snapshot-interface.ts @@ -5,11 +5,11 @@ export interface IIssuedInvoiceTaxFullSnapshot { iva_percentage: { value: string; scale: string }; iva_amount: { value: string; scale: string; currency_code: string }; - rec_code: string; + rec_code: string | null; rec_percentage: { value: string; scale: string }; rec_amount: { value: string; scale: string; currency_code: string }; - retention_code: string; + retention_code: string | null; retention_percentage: { value: string; scale: string }; retention_amount: { value: string; scale: string; currency_code: string }; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-taxes-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-taxes-full-snapshot-builder.ts index 30d73e65..6e7c8d1f 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-taxes-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-taxes-full-snapshot-builder.ts @@ -1,5 +1,5 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { maybeToEmptyPercentageObjectString, maybeToEmptyString } from "@repo/rdx-ddd"; +import { maybeToEmptyPercentageObjectString, maybeToNullable } from "@repo/rdx-ddd"; import type { IssuedInvoiceTax, IssuedInvoiceTaxes } from "../../../../domain"; @@ -19,11 +19,11 @@ export class IssuedInvoiceTaxesFullSnapshotBuilder iva_percentage: invoiceTax.ivaPercentage.toObjectString(), iva_amount: invoiceTax.ivaAmount.toObjectString(), - rec_code: maybeToEmptyString(invoiceTax.recCode), + rec_code: maybeToNullable(invoiceTax.recCode, (v) => v.toString()), rec_percentage: maybeToEmptyPercentageObjectString(invoiceTax.recPercentage), rec_amount: invoiceTax.recAmount.toObjectString(), - retention_code: maybeToEmptyString(invoiceTax.retentionCode), + retention_code: maybeToNullable(invoiceTax.retentionCode, (v) => v.toString()), retention_percentage: maybeToEmptyPercentageObjectString(invoiceTax.retentionPercentage), retention_amount: invoiceTax.retentionAmount.toObjectString(), diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-verifactu-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-verifactu-full-snapshot.interface.ts index 2209979a..99ed19ec 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-verifactu-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-verifactu-full-snapshot.interface.ts @@ -1,6 +1,10 @@ +/** + * Fijarse en VerifactuRecordSchema + */ + export interface IIssuedInvoiceVerifactuFullSnapshot { id: string; status: string; - url: string; - qr_code: string; + url: string | null; + qr_code: string | null; } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/summary/issued-invoice-summary-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/summary/issued-invoice-summary-snapshot-builder.ts index afca7ef6..fdb4f896 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/summary/issued-invoice-summary-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/summary/issued-invoice-summary-snapshot-builder.ts @@ -1,5 +1,6 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { maybeToEmptyString } from "@repo/rdx-ddd"; +import { VERIFACTU_RECORD_STATUS } from "@erp/customer-invoices/api/domain"; +import { maybeToEmptyString, maybeToNullable } from "@repo/rdx-ddd"; import type { IssuedInvoiceSummary } from "../../models"; @@ -13,19 +14,24 @@ export class IssuedInvoiceSummarySnapshotBuilder implements IIssuedInvoiceSummar const recipient = invoice.recipient.toObjectString(); const verifactu = invoice.verifactu.match( - (v) => v.toObjectString(), + (v) => { + return { + status: v.estado.toString(), + url: maybeToNullable(v.url, (u) => u.toString()), + qr_code: maybeToNullable(v.qrCode, (q) => q.toString()), + }; + }, () => ({ - status: "", - url: "", - qr_code: "", + status: VERIFACTU_RECORD_STATUS.PENDIENTE, + url: null, + qr_code: null, }) ); return { id: invoice.id.toString(), company_id: invoice.companyId.toString(), - - customer_id: invoice.customerId.toString(), + is_proforma: invoice.isProforma, invoice_number: invoice.invoiceNumber.toString(), status: invoice.status.toPrimitive(), @@ -36,7 +42,12 @@ export class IssuedInvoiceSummarySnapshotBuilder implements IIssuedInvoiceSummar reference: maybeToEmptyString(invoice.reference, (v) => v.toString()), description: maybeToEmptyString(invoice.description, (v) => v.toString()), - recipient, + customer_id: invoice.customerId.toString(), + + recipient: { + id: invoice.customerId.toString(), + ...recipient, + }, language_code: invoice.languageCode.code, currency_code: invoice.currencyCode.code, @@ -49,9 +60,7 @@ export class IssuedInvoiceSummarySnapshotBuilder implements IIssuedInvoiceSummar verifactu, - metadata: { - entity: "issued-invoice", - }, + linked_proforma_id: maybeToNullable(invoice.linkedProformaId, (v) => v.toString()), }; } } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/summary/issued-invoice-summary-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/summary/issued-invoice-summary-snapshot.interface.ts index b64675f5..3a8f2a59 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/summary/issued-invoice-summary-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/summary/issued-invoice-summary-snapshot.interface.ts @@ -1,8 +1,10 @@ +/** + * Fijarse en ListIssuedInvoicesResponseDTO["items"] + */ export interface IIssuedInvoiceSummarySnapshot { id: string; company_id: string; - - customer_id: string; + is_proforma: boolean; invoice_number: string; status: string; @@ -14,18 +16,20 @@ export interface IIssuedInvoiceSummarySnapshot { language_code: string; currency_code: string; - reference: string; + reference: string | null; description: string; + customer_id: string; recipient: { + id: string; tin: string; name: string; - street: string; - street2: string; - city: string; - postal_code: string; - province: string; - country: string; + street: string | null; + street2: string | null; + city: string | null; + postal_code: string | null; + province: string | null; + country: string | null; }; subtotal_amount: { value: string; scale: string; currency_code: string }; @@ -36,9 +40,9 @@ export interface IIssuedInvoiceSummarySnapshot { verifactu: { status: string; - url: string; - qr_code: string; + url: string | null; + qr_code: string | null; }; - metadata?: Record; + linked_proforma_id: string | null; } diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts index deb81031..67bcf7af 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts @@ -1,5 +1,5 @@ -import type { JsonTaxCatalogProvider } from "@erp/core"; -import { DiscountPercentage, Tax } from "@erp/core/api"; +import { type JsonTaxCatalogProvider, NumberHelper } from "@erp/core"; +import { DiscountPercentage } from "@erp/core/api"; import { CurrencyCode, DomainError, @@ -15,7 +15,7 @@ import { } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../common"; +import type { CreateProformaRequestDTO } from "../../../../common"; import { type IProformaCreateProps, type IProformaItemCreateProps, @@ -27,6 +27,7 @@ import { ItemAmount, ItemDescription, ItemQuantity, + ProformaItemTaxes, type ProformaItemTaxesProps, } from "../../../domain"; @@ -221,19 +222,25 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ ); const quantity = extractOrPushError( - maybeFromNullableResult(item.quantity, (v) => ItemQuantity.create(v)), + maybeFromNullableResult(item.quantity, (v) => + ItemQuantity.create({ value: NumberHelper.toSafeNumber(v) }) + ), `items[${index}].quantity`, params.errors ); const unitAmount = extractOrPushError( - maybeFromNullableResult(item.unit_amount, (v) => ItemAmount.create(v)), + maybeFromNullableResult(item.unit_amount, (v) => + ItemAmount.create({ value: NumberHelper.toSafeNumber(v) }) + ), `items[${index}].unit_amount`, params.errors ); const discountPercentage = extractOrPushError( - maybeFromNullableResult(item.item_discount_percentage, (v) => DiscountPercentage.create(v)), + maybeFromNullableResult(item.item_discount_percentage, (v) => + DiscountPercentage.create({ value: NumberHelper.toSafeNumber(v.value) }) + ), `items[${index}].discount_percentage`, params.errors ); @@ -264,10 +271,14 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ /* Devuelve las propiedades de los impustos de una línea de detalle */ private mapTaxesProps( - taxesDTO: Pick["taxes"], + taxesDTO: NonNullable[number]["taxes"], params: { itemIndex: number; errors: ValidationErrorDetail[] } ): ProformaItemTaxesProps { - const { itemIndex, errors } = params; + // TODO: POR AHORA SE QUEDA ASÍ + + return ProformaItemTaxes.empty().getProps(); + + /*const { itemIndex, errors } = params; const taxesProps: ProformaItemTaxesProps = { iva: Maybe.none(), @@ -275,7 +286,6 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ rec: Maybe.none(), }; - // Normaliza: "" -> [] const taxStrCodes = taxesDTO .split(",") .map((s) => s.trim()) @@ -328,5 +338,6 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/ this.throwIfValidationErrors(errors); return taxesProps; + */ } } diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts index 860124ee..2dacb0f2 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts @@ -21,6 +21,8 @@ import { ItemDescription, ItemQuantity, type ProformaItemPatchProps, + ProformaItemTaxes, + type ProformaItemTaxesProps, type ProformaPatchProps, } from "../../../domain"; @@ -198,10 +200,10 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { params.errors ); - /*const taxes = this.mapTaxesProps(item.taxes, { + const taxes = this.mapTaxesProps(item.taxes, { itemIndex: index, errors: params.errors, - });*/ + }); this.throwIfValidationErrors(params.errors); @@ -210,20 +212,24 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { quantity: quantity!, unitAmount: unitAmount!, itemDiscountPercentage: discountPercentage!, - //taxes, + taxes, }); }); return itemsProps; } - /* Devuelve las propiedades de los impustos de una línea de detalle */ + /* Devuelve las propiedades de los impuestos de una línea de detalle */ - /*private mapTaxesProps( - taxesDTO: Pick["taxes"], + private mapTaxesProps( + taxesDTO: NonNullable[number]["taxes"], params: { itemIndex: number; errors: ValidationErrorDetail[] } ): ProformaItemTaxesProps { - const { itemIndex, errors } = params; + // TODO: POR AHORA SE QUEDA ASÍ + + return ProformaItemTaxes.empty().getProps(); + + /*const { itemIndex, errors } = params; const taxesProps: ProformaItemTaxesProps = { iva: Maybe.none(), @@ -231,7 +237,6 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { rec: Maybe.none(), }; - // Normaliza: "" -> [] const taxStrCodes = taxesDTO .split(",") .map((s) => s.trim()) @@ -283,6 +288,6 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { this.throwIfValidationErrors(errors); - return taxesProps; - }*/ + return taxesProps;*/ + } } diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts index 53abc535..adb8fabb 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts @@ -1,5 +1,5 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { maybeToEmptyString } from "@repo/rdx-ddd"; +import { maybeToNullable } from "@repo/rdx-ddd"; import type { Proforma } from "../../../../domain"; @@ -31,7 +31,7 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder payment_description, }; }, - () => undefined + () => null ); const allTotals = proforma.totals(); @@ -40,16 +40,17 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder id: proforma.id.toString(), company_id: proforma.companyId.toString(), + is_proforma: true, invoice_number: proforma.invoiceNumber.toString(), status: proforma.status.toPrimitive(), - series: maybeToEmptyString(proforma.series, (value) => value.toString()), + series: maybeToNullable(proforma.series, (value) => value.toString()), invoice_date: proforma.invoiceDate.toDateString(), - operation_date: maybeToEmptyString(proforma.operationDate, (value) => value.toDateString()), + operation_date: maybeToNullable(proforma.operationDate, (value) => value.toDateString()), - reference: maybeToEmptyString(proforma.reference, (value) => value.toString()), - description: maybeToEmptyString(proforma.description, (value) => value.toString()), - notes: maybeToEmptyString(proforma.notes, (value) => value.toString()), + reference: maybeToNullable(proforma.reference, (value) => value.toString()), + description: maybeToNullable(proforma.description, (value) => value.toString()), + notes: maybeToNullable(proforma.notes, (value) => value.toString()), language_code: proforma.languageCode.toString(), currency_code: proforma.currencyCode.toString(), @@ -57,6 +58,8 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder customer_id: proforma.customerId.toString(), recipient, + linked_invoice_id: maybeToNullable(proforma.linkedInvoiceId, (value) => value.toString()), + payment_method: payment, subtotal_amount: allTotals.subtotalAmount.toObjectString(), diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot.interface.ts index fc327fc7..8e460941 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot.interface.ts @@ -2,20 +2,25 @@ import type { IProformaItemFullSnapshot } from "./proforma-item-full-snapshot.in import type { IProformaRecipientFullSnapshot } from "./proforma-recipient-full-snapshot.interface"; import type { IProformaTaxFullSnapshot } from "./proforma-tax-full-snapshot-interface"; +/** + * Fijarse en GetProformaByIdResponseDTO + */ + export interface IProformaFullSnapshot { id: string; company_id: string; + is_proforma: boolean; invoice_number: string; status: string; - series: string; + series: string | null; invoice_date: string; - operation_date: string; + operation_date: string | null; - reference: string; - description: string; - notes: string; + reference: string | null; + description: string | null; + notes: string | null; language_code: string; currency_code: string; @@ -23,10 +28,14 @@ export interface IProformaFullSnapshot { customer_id: string; recipient: IProformaRecipientFullSnapshot; - payment_method?: { + linked_invoice_id: string | null; + + taxes: IProformaTaxFullSnapshot[]; + + payment_method: { payment_id: string; payment_description: string; - }; + } | null; subtotal_amount: { value: string; scale: string; currency_code: string }; items_discount_amount: { value: string; scale: string; currency_code: string }; @@ -45,9 +54,7 @@ export interface IProformaFullSnapshot { taxes_amount: { value: string; scale: string; currency_code: string }; total_amount: { value: string; scale: string; currency_code: string }; - taxes: IProformaTaxFullSnapshot[]; - items: IProformaItemFullSnapshot[]; - metadata?: Record; + metadata: Record | null; } diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-item-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-item-full-snapshot.interface.ts index 37c94a5c..8c8d098d 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-item-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-item-full-snapshot.interface.ts @@ -1,8 +1,8 @@ export interface IProformaItemFullSnapshot { id: string; - is_valued: string; - position: string; - description: string; + is_valued: boolean; + position: number; + description: string | null; quantity: { value: string; scale: string }; unit_amount: { value: string; scale: string; currency_code: string }; diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts index 4d9c4fda..a5570dbc 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts @@ -4,6 +4,7 @@ import { maybeToEmptyPercentageObjectString, maybeToEmptyQuantityObjectString, maybeToEmptyString, + maybeToNullable, } from "@repo/rdx-ddd"; import { ItemAmount, type ProformaItem, type ProformaItems } from "../../../../domain"; @@ -20,10 +21,10 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps return { id: proformaItem.id.toPrimitive(), - is_valued: String(isValued), - position: String(index), + is_valued: isValued, + position: index, - description: maybeToEmptyString(proformaItem.description, (value) => value.toString()), + description: maybeToNullable(proformaItem.description, (value) => value.toString()), quantity: maybeToEmptyQuantityObjectString(proformaItem.quantity), unit_amount: maybeToEmptyMoneyObjectString(proformaItem.unitAmount), diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-recipient-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-recipient-full-snapshot-builder.ts index 5a806295..6d3637cb 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-recipient-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-recipient-full-snapshot-builder.ts @@ -1,5 +1,5 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { DomainValidationError, maybeToEmptyString } from "@repo/rdx-ddd"; +import { DomainValidationError, maybeToNullable } from "@repo/rdx-ddd"; import type { InvoiceRecipient, Proforma } from "../../../../domain"; @@ -21,24 +21,25 @@ export class ProformaRecipientFullSnapshotBuilder implements IProformaRecipientF id: proforma.customerId.toString(), name: recipient.name.toString(), tin: recipient.tin.toString(), - street: maybeToEmptyString(recipient.street, (v) => v.toString()), - street2: maybeToEmptyString(recipient.street2, (v) => v.toString()), - city: maybeToEmptyString(recipient.city, (v) => v.toString()), - province: maybeToEmptyString(recipient.province, (v) => v.toString()), - postal_code: maybeToEmptyString(recipient.postalCode, (v) => v.toString()), - country: maybeToEmptyString(recipient.country, (v) => v.toString()), + street: maybeToNullable(recipient.street, (v) => v.toString()), + street2: maybeToNullable(recipient.street2, (v) => v.toString()), + city: maybeToNullable(recipient.city, (v) => v.toString()), + province: maybeToNullable(recipient.province, (v) => v.toString()), + postal_code: maybeToNullable(recipient.postalCode, (v) => v.toString()), + country: maybeToNullable(recipient.country, (v) => v.toString()), }), - () => ({ - id: "", - name: "", - tin: "", - street: "", - street2: "", - city: "", - province: "", - postal_code: "", - country: "", - }) + () => + ({ + id: null, + name: null, + tin: null, + street: null, + street2: null, + city: null, + province: null, + postal_code: null, + country: null, + }) as IProformaRecipientFullSnapshot ); } } diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-recipient-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-recipient-full-snapshot.interface.ts index cb390028..606d806f 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-recipient-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-recipient-full-snapshot.interface.ts @@ -1,11 +1,15 @@ +/** + * Fijarse en ProformaRecipientSummarySchema + */ + export interface IProformaRecipientFullSnapshot { - id: string; - name: string; - tin: string; - street: string; - street2: string; - city: string; - province: string; - postal_code: string; - country: string; + id: string | null; + name: string | null; + tin: string | null; + street: string | null; + street2: string | null; + city: string | null; + province: string | null; + postal_code: string | null; + country: string | null; } diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts index df0d5554..530aae43 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts @@ -1,5 +1,5 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { maybeToEmptyString } from "@repo/rdx-ddd"; +import { maybeToEmptyString, maybeToNullable } from "@repo/rdx-ddd"; import type { ProformaSummary } from "../../models"; @@ -15,18 +15,22 @@ export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotB return { id: proforma.id.toString(), company_id: proforma.companyId.toString(), - customer_id: proforma.customerId.toString(), + is_proforma: proforma.isProforma, invoice_number: proforma.invoiceNumber.toString(), status: proforma.status.toPrimitive(), - series: maybeToEmptyString(proforma.series, (value) => value.toString()), + series: maybeToNullable(proforma.series, (value) => value.toString()), invoice_date: proforma.invoiceDate.toDateString(), - operation_date: maybeToEmptyString(proforma.operationDate, (value) => value.toDateString()), + operation_date: maybeToNullable(proforma.operationDate, (value) => value.toDateString()), reference: maybeToEmptyString(proforma.reference, (value) => value.toString()), - description: maybeToEmptyString(proforma.description, (value) => value.toString()), + description: maybeToNullable(proforma.description, (value) => value.toString()), - recipient, + customer_id: proforma.customerId.toString(), + recipient: { + id: proforma.customerId.toString(), + ...recipient, + }, language_code: proforma.languageCode.code, currency_code: proforma.currencyCode.code, @@ -37,11 +41,7 @@ export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotB taxes_amount: proforma.taxesAmount.toObjectString(), total_amount: proforma.totalAmount.toObjectString(), - linked_invoice_id: maybeToEmptyString(proforma.linkedInvoiceId, (value) => value.toString()), - - metadata: { - entity: "proforma", - }, + linked_invoice_id: maybeToNullable(proforma.linkedInvoiceId, (value) => value.toString()), }; } } diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot.interface.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot.interface.ts index 074b16c7..4bf481a1 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot.interface.ts @@ -1,31 +1,35 @@ +/** + * Fijarse en ListProformasResponseDTO["items"] + */ export interface IProformaSummarySnapshot { id: string; company_id: string; - - customer_id: string; + is_proforma: boolean; invoice_number: string; status: string; - series: string; + series: string | null; invoice_date: string; - operation_date: string; + operation_date: string | null; language_code: string; currency_code: string; - reference: string; - description: string; + reference: string | null; + description: string | null; + customer_id: string; recipient: { + id: string; tin: string; name: string; - street: string; - street2: string; - city: string; - postal_code: string; - province: string; - country: string; + street: string | null; + street2: string | null; + city: string | null; + postal_code: string | null; + province: string | null; + country: string | null; }; subtotal_amount: { value: string; scale: string; currency_code: string }; @@ -34,7 +38,5 @@ export interface IProformaSummarySnapshot { taxes_amount: { value: string; scale: string; currency_code: string }; total_amount: { value: string; scale: string; currency_code: string }; - linked_invoice_id: string; - - metadata?: Record; + linked_invoice_id: string | null; } diff --git a/modules/customer-invoices/src/api/domain/common/value-objects/invoice-recipient/invoice-recipient.vo.ts b/modules/customer-invoices/src/api/domain/common/value-objects/invoice-recipient/invoice-recipient.vo.ts index f5169e20..3097cb0d 100644 --- a/modules/customer-invoices/src/api/domain/common/value-objects/invoice-recipient/invoice-recipient.vo.ts +++ b/modules/customer-invoices/src/api/domain/common/value-objects/invoice-recipient/invoice-recipient.vo.ts @@ -7,7 +7,7 @@ import { type Street, type TINNumber, ValueObject, - maybeToEmptyString, + maybeToNullable, } from "@repo/rdx-ddd"; import { type Maybe, Result } from "@repo/rdx-utils"; @@ -90,12 +90,12 @@ export class InvoiceRecipient extends ValueObject { return { tin: this.tin.toString(), name: this.name.toString(), - street: maybeToEmptyString(this.street, (value) => value.toString()), - street2: maybeToEmptyString(this.street2, (value) => value.toString()), - city: maybeToEmptyString(this.city, (value) => value.toString()), - postal_code: maybeToEmptyString(this.postalCode, (value) => value.toString()), - province: maybeToEmptyString(this.province, (value) => value.toString()), - country: maybeToEmptyString(this.country, (value) => value.toString()), + street: maybeToNullable(this.street, (value) => value.toString()), + street2: maybeToNullable(this.street2, (value) => value.toString()), + city: maybeToNullable(this.city, (value) => value.toString()), + postal_code: maybeToNullable(this.postalCode, (value) => value.toString()), + province: maybeToNullable(this.province, (value) => value.toString()), + country: maybeToNullable(this.country, (value) => value.toString()), }; } } diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts b/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts index 44ee0f11..9443f84b 100644 --- a/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts @@ -32,19 +32,19 @@ export interface IIssuedInvoiceCreateProps { companyId: UniqueID; status: InvoiceStatus; - proformaId: UniqueID; // <- id de la proforma padre en caso de issue + linkedProformaId: Maybe; // <- id de la proforma padre en caso de issue - series: Maybe; + series: InvoiceSerie; invoiceNumber: InvoiceNumber; invoiceDate: UtcDate; operationDate: Maybe; customerId: UniqueID; - recipient: Maybe; + recipient: InvoiceRecipient; reference: Maybe; - description: Maybe; + description: string; notes: Maybe; languageCode: LanguageCode; @@ -178,15 +178,15 @@ export class IssuedInvoice return this.props.customerId; } - public get proformaId(): UniqueID { - return this.props.proformaId; + public get linkedProformaId(): Maybe { + return this.props.linkedProformaId; } public get status(): InvoiceStatus { return this.props.status; } - public get series(): Maybe { + public get series(): InvoiceSerie { return this.props.series; } @@ -206,7 +206,7 @@ export class IssuedInvoice return this.props.reference; } - public get description(): Maybe { + public get description(): string { return this.props.description; } @@ -214,7 +214,7 @@ export class IssuedInvoice return this.props.notes; } - public get recipient(): Maybe { + public get recipient(): InvoiceRecipient { return this.props.recipient; } @@ -287,10 +287,6 @@ export class IssuedInvoice return this._items; } - public get hasRecipient() { - return this.recipient.isSome(); - } - public get hasPaymentMethod() { return this.paymentMethod.isSome(); } diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index 6f683e9f..33ac50ae 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -35,8 +35,8 @@ export interface IProformaCreateProps { companyId: UniqueID; status: InvoiceStatus; - series: Maybe; invoiceNumber: InvoiceNumber; + series: Maybe; invoiceDate: UtcDate; operationDate: Maybe; @@ -51,6 +51,8 @@ export interface IProformaCreateProps { languageCode: LanguageCode; currencyCode: CurrencyCode; + linkedInvoiceId: Maybe; + paymentMethod: Maybe; items: IProformaItemCreateProps[]; @@ -100,17 +102,19 @@ export interface IProforma { paymentMethod: Maybe; + linkedInvoiceId: Maybe; + items: IProformaItems; // <- Colección taxes(): Collection; totals(): IProformaTotals; } -export type InternalProformaProps = Omit; +export type ProformaInternalProps = Omit; -export class Proforma extends AggregateRoot implements IProforma { - private readonly _items: ProformaItems; +export class Proforma extends AggregateRoot implements IProforma { + private _items: ProformaItems; - protected constructor(props: InternalProformaProps, items: ProformaItems, id?: UniqueID) { + protected constructor(props: ProformaInternalProps, items: ProformaItems, id?: UniqueID) { super(props, id); this._items = items; } @@ -153,7 +157,7 @@ export class Proforma extends AggregateRoot implements IP } // Rehidratación desde persistencia - static rehydrate(props: InternalProformaProps, items: ProformaItems, id: UniqueID): Proforma { + static rehydrate(props: ProformaInternalProps, items: ProformaItems, id: UniqueID): Proforma { return new Proforma(props, items, id); } @@ -161,7 +165,7 @@ export class Proforma extends AggregateRoot implements IP public update(patchProps: ProformaPatchProps): Result { const { items, ...otherProps } = patchProps; - const candidateProps: InternalProformaProps = { + const candidateProps: ProformaInternalProps = { ...this.props, ...otherProps, }; @@ -261,6 +265,10 @@ export class Proforma extends AggregateRoot implements IP return this.props.paymentMethod; } + public get linkedInvoiceId(): Maybe { + return this.props.linkedInvoiceId; + } + public get languageCode(): LanguageCode { return this.props.languageCode; } diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/controllers/get-issued-invoice-by-id.controller.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/controllers/get-issued-invoice-by-id.controller.ts index e7198c74..6195746d 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/controllers/get-issued-invoice-by-id.controller.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/controllers/get-issued-invoice-by-id.controller.ts @@ -33,6 +33,7 @@ export class GetIssuedInvoiceByIdController extends ExpressController { return result.match( (data) => { + console.log(data); const dto = GetIssuedInvoiceByIdResponseSchema.parse(data); return this.ok(dto); }, diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts index 3a9819d3..d9df9f9e 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts @@ -63,20 +63,18 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors); - // Para issued invoices, proforma_id debe estar relleno - const proformaId = extractOrPushError( - UniqueID.create(String(raw.proforma_id)), + const linkedProformaId = extractOrPushError( + maybeFromNullableResult(raw.proforma_id, (v) => UniqueID.create(String(v))), "proforma_id", errors ); const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors); - const series = extractOrPushError( - maybeFromNullableResult(raw.series, (v) => InvoiceSerie.create(v)), - "series", - errors - ); + // En el caso de "serie", al ser un campo opcional en BD + // pero obligatorio en el dominio, + // se le asignará un valor por defecto (cadena vacía) en caso de venir como nullish, para que así el VO lo valide y lance el error correspondiente. De esta forma evitamos que el mapper devuelva un null o undefined en el campo 'series' del dominio, lo cual podría generar errores difíciles de depurar posteriormente en el código. + const series = extractOrPushError(InvoiceSerie.create(raw.series ?? ""), "series", errors); const invoiceNumber = extractOrPushError( InvoiceNumber.create(raw.invoice_number), @@ -117,8 +115,11 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< errors ); + // En el caso de "description", al ser un campo opcional en BD + // pero obligatorio en el dominio, + // se le asignará un valor por defecto (cadena vacía) en caso de venir como nullish, para que así el VO lo valide y lance el error correspondiente. De esta forma evitamos que el mapper devuelva un null o undefined en el campo 'series' del dominio, lo cual podría generar errores difíciles de depurar posteriormente en el código. const description = extractOrPushError( - maybeFromNullableResult(raw.description, (value) => Result.ok(String(value))), + Result.ok(String(raw.description ?? "")), "description", errors ); @@ -258,7 +259,6 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< invoiceId, companyId, customerId, - proformaId, status, series, invoiceNumber, @@ -282,6 +282,8 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< retentionAmount, taxesAmount, totalAmount, + + linkedProformaId, }; } @@ -350,7 +352,6 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< const invoiceProps: InternalIssuedInvoiceProps = { companyId: attributes.companyId!, - proformaId: attributes.proformaId!, status: attributes.status!, series: attributes.series!, invoiceNumber: attributes.invoiceNumber!, @@ -386,6 +387,8 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< taxes, verifactu, + + linkedProformaId: attributes.linkedProformaId!, }; const invoiceId = attributes.invoiceId!; @@ -464,9 +467,9 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< // Flags / estado / serie / número is_proforma: false, status: source.status.toPrimitive(), - proforma_id: source.proformaId.toPrimitive(), + proforma_id: maybeToNullable(source.linkedProformaId, (v) => v.toPrimitive()), - series: maybeToNullable(source.series, (v) => v.toPrimitive()), + series: source.series.toPrimitive(), invoice_number: source.invoiceNumber.toPrimitive(), invoice_date: source.invoiceDate.toPrimitive(), operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()), @@ -474,7 +477,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< currency_code: source.currencyCode.toPrimitive(), reference: maybeToNullable(source.reference, (reference) => reference), - description: maybeToNullable(source.description, (description) => description), + description: source.description, notes: maybeToNullable(source.notes, (v) => v.toPrimitive()), payment_method_id: maybeToNullable( diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts index 3a0ab89c..78f5d1d2 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts @@ -13,7 +13,7 @@ import { maybeFromNullableResult, maybeToNullable, } from "@repo/rdx-ddd"; -import { Maybe, Result } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; import { type IIssuedInvoiceCreateProps, @@ -26,7 +26,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper { public mapToDomain( source: CustomerInvoiceModel, params?: MapperParamsType - ): Result, Error> { + ): Result { /** * - Issued invoice -> snapshot de los datos (campos customer_*) */ @@ -105,7 +105,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper { ); } - return Result.ok(Maybe.some(createResult.data)); + return Result.ok(createResult.data); } /** @@ -117,7 +117,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper { * - Si la factura no es proforma (`isProforma === false`), debe existir `recipient`. * En caso contrario, se agrega un error de validación. */ - mapToPersistence(source: Maybe, params?: MapperParamsType) { + mapToPersistence(source: InvoiceRecipient, params?: MapperParamsType) { const { errors, parent } = params as { parent: IssuedInvoice; errors: ValidationErrorDetail[]; @@ -140,7 +140,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper { ); } - const recipient = source.unwrap(); + const recipient = source; return { customer_tin: recipient.tin.toPrimitive(), diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts index ff110981..4a868c7d 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts @@ -28,7 +28,7 @@ export class SequelizeIssuedInvoiceVerifactuDomainMapper extends SequelizeDomain Maybe > { public mapToDomain( - source: VerifactuRecordModel, + source: VerifactuRecordModel | null | undefined, params?: MapperParamsType ): Result, Error> { const { errors, attributes } = params as { diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts index be6877bb..6304fb61 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts @@ -110,6 +110,8 @@ export class SequelizeIssuedInvoiceSummaryMapper extends SequelizeQueryMapper< taxesAmount: attributes.taxesAmount!, totalAmount: attributes.totalAmount!, + linkedProformaId: attributes.linkedProformaId!, + verifactu, }); } @@ -221,6 +223,12 @@ export class SequelizeIssuedInvoiceSummaryMapper extends SequelizeQueryMapper< errors ); + const linkedProformaId = extractOrPushError( + maybeFromNullableResult(raw.proforma_id, (value) => UniqueID.create(value)), + "linked_proforma_id", + errors + ); + return { invoiceId, companyId, @@ -241,6 +249,8 @@ export class SequelizeIssuedInvoiceSummaryMapper extends SequelizeQueryMapper< taxableAmount, taxesAmount, totalAmount, + + linkedProformaId, }; } } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts index 9054cb2e..bd605796 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts @@ -14,12 +14,12 @@ import { import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils"; import { - type InternalProformaProps, InvoiceNumber, InvoicePaymentMethod, InvoiceSerie, InvoiceStatus, Proforma, + type ProformaInternalProps, ProformaItems, } from "../../../../../../domain"; import type { @@ -150,6 +150,12 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< errors ); + const linkedInvoiceId = extractOrPushError( + maybeFromNullableResult(raw.linked_invoice?.id, (value) => UniqueID.create(value)), + "linked_invoice_id", + errors + ); + return { invoiceId, companyId, @@ -167,6 +173,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< paymentMethod, globalDiscountPercentage, + linkedInvoiceId, }; } @@ -213,7 +220,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< items: itemCollectionResults.data.getAll(), }); - const invoiceProps: InternalProformaProps = { + const invoiceProps: ProformaInternalProps = { companyId: attributes.companyId!, status: attributes.status!, @@ -235,6 +242,8 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< globalDiscountPercentage: attributes.globalDiscountPercentage!, paymentMethod: attributes.paymentMethod!, + + linkedInvoiceId: attributes.linkedInvoiceId!, // El id de la factura emitida (linked_invoice) se asigna al hacer issue() desde la proforma, no viene en el modelo de persistencia. }; const proformaId = attributes.invoiceId!; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts index ae070b0b..a0afe5e6 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts @@ -58,7 +58,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper< const { errors, index, parent } = params as { index: number; errors: ValidationErrorDetail[]; - parent: Partial; + parent: Partial; }; const itemId = extractOrPushError( diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts index 21d3f27c..735d6ec5 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts @@ -303,6 +303,12 @@ export class ProformaRepository as: "taxes", required: false, }, + { + model: CustomerInvoiceModel, + as: "linked_invoice", + required: false, + attributes: ["id"], + }, ], transaction, }; diff --git a/modules/customer-invoices/src/common/dto/index.ts b/modules/customer-invoices/src/common/dto/index.ts index 346dac3b..32497d2a 100644 --- a/modules/customer-invoices/src/common/dto/index.ts +++ b/modules/customer-invoices/src/common/dto/index.ts @@ -1,2 +1,3 @@ export * from "./request"; export * from "./response"; +export * from "./shared"; diff --git a/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts index e56f0328..9b798669 100644 --- a/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts @@ -1,119 +1,68 @@ -import { MetadataSchema, MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core"; +import { + CurrencyCodeSchema, + IsoDateSchema, + LanguageCodeSchema, + MetadataSchema, + MoneySchema, + PercentageSchema, +} from "@erp/core"; import { z } from "zod/v4"; +import { + IssuedInvoiceItemDetailSchema, + IssuedInvoiceRecipientSummarySchema, + IssuedInvoiceStatusSchema, + PaymentMethodRefSchema, + TaxesBreakdownSchema, + VerifactuRecordSchema, +} from "../../shared"; + export const GetIssuedInvoiceByIdResponseSchema = z.object({ id: z.uuid(), company_id: z.uuid(), + is_proforma: z.boolean(), invoice_number: z.string(), - status: z.string(), + status: IssuedInvoiceStatusSchema, series: z.string(), - invoice_date: z.string(), - operation_date: z.string(), + invoice_date: IsoDateSchema, + operation_date: IsoDateSchema.nullable(), - reference: z.string(), + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, + + reference: z.string().nullable(), description: z.string(), - notes: z.string(), + notes: z.string().nullable(), - language_code: z.string(), - currency_code: z.string(), + customer_id: z.uuid(), + recipient: IssuedInvoiceRecipientSummarySchema, - customer_id: z.string(), - recipient: z.object({ - id: z.string(), - name: z.string(), - tin: z.string(), - street: z.string(), - street2: z.string(), - city: z.string(), - province: z.string(), - postal_code: z.string(), - country: z.string(), - }), + linked_proforma_id: z.uuid().nullable(), - taxes: z.array( - z.object({ - taxable_amount: MoneySchema, + taxes: z.array(TaxesBreakdownSchema), - iva_code: z.string(), - iva_percentage: PercentageSchema, - iva_amount: MoneySchema, - - rec_code: z.string(), - rec_percentage: PercentageSchema, - rec_amount: MoneySchema, - - retention_code: z.string(), - retention_percentage: PercentageSchema, - retention_amount: MoneySchema, - - taxes_amount: MoneySchema, - }) - ), - - payment_method: z - .object({ - payment_id: z.string(), - payment_description: z.string(), - }) - .optional(), + payment_method: PaymentMethodRefSchema.nullable(), subtotal_amount: MoneySchema, + items_discount_amount: MoneySchema, global_discount_percentage: PercentageSchema, global_discount_amount: MoneySchema, total_discount_amount: MoneySchema, + taxable_amount: MoneySchema, + iva_amount: MoneySchema, rec_amount: MoneySchema, retention_amount: MoneySchema, + taxes_amount: MoneySchema, total_amount: MoneySchema, - verifactu: z.object({ - status: z.string(), - url: z.string(), - qr_code: z.string(), - }), - - items: z.array( - z.object({ - id: z.uuid(), - is_valued: z.string(), - position: z.string(), - description: z.string(), - - quantity: QuantitySchema, - unit_amount: MoneySchema, - - subtotal_amount: MoneySchema, - - item_discount_percentage: PercentageSchema, - item_discount_amount: MoneySchema, - - global_discount_percentage: PercentageSchema, - global_discount_amount: MoneySchema, - - taxable_amount: MoneySchema, - - iva_code: z.string(), - iva_percentage: PercentageSchema, - iva_amount: MoneySchema, - - rec_code: z.string(), - rec_percentage: PercentageSchema, - rec_amount: MoneySchema, - - retention_code: z.string(), - retention_percentage: PercentageSchema, - retention_amount: MoneySchema, - - taxes_amount: MoneySchema, - - total_amount: MoneySchema, - }) - ), + verifactu: VerifactuRecordSchema, + items: z.array(IssuedInvoiceItemDetailSchema), metadata: MetadataSchema.optional(), }); diff --git a/modules/customer-invoices/src/common/dto/response/issued-invoices/list-issued-invoices.response.dto.ts b/modules/customer-invoices/src/common/dto/response/issued-invoices/list-issued-invoices.response.dto.ts index 768f8dab..64bca415 100644 --- a/modules/customer-invoices/src/common/dto/response/issued-invoices/list-issued-invoices.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/issued-invoices/list-issued-invoices.response.dto.ts @@ -1,36 +1,39 @@ -import { MetadataSchema, MoneySchema, createPaginatedListSchema } from "@erp/core"; +import { + CurrencyCodeSchema, + IsoDateSchema, + LanguageCodeSchema, + MoneySchema, + createPaginatedListSchema, +} from "@erp/core"; import { z } from "zod/v4"; +import { + IssuedInvoiceRecipientSummarySchema, + IssuedInvoiceStatusSchema, + VerifactuRecordSchema, +} from "../../shared"; + export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema( z.object({ id: z.uuid(), company_id: z.uuid(), - - customer_id: z.string(), + is_proforma: z.boolean(), invoice_number: z.string(), - status: z.string(), + status: IssuedInvoiceStatusSchema, series: z.string(), - invoice_date: z.string(), - operation_date: z.string(), + invoice_date: IsoDateSchema, + operation_date: IsoDateSchema.nullable(), - language_code: z.string(), - currency_code: z.string(), + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, - reference: z.string(), + reference: z.string().nullable(), description: z.string(), - recipient: z.object({ - tin: z.string(), - name: z.string(), - street: z.string(), - street2: z.string(), - city: z.string(), - postal_code: z.string(), - province: z.string(), - country: z.string(), - }), + customer_id: z.uuid(), + recipient: IssuedInvoiceRecipientSummarySchema, subtotal_amount: MoneySchema, total_discount_amount: MoneySchema, @@ -38,13 +41,9 @@ export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema( taxes_amount: MoneySchema, total_amount: MoneySchema, - verifactu: z.object({ - status: z.string(), - url: z.string(), - qr_code: z.string(), - }), + verifactu: VerifactuRecordSchema, - metadata: MetadataSchema.optional(), + linked_proforma_id: z.uuid().nullable(), }) ); diff --git a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts index eb28a7e0..5c1ad40f 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts @@ -1,69 +1,52 @@ -import { MetadataSchema, MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core"; +import { + CurrencyCodeSchema, + IsoDateSchema, + LanguageCodeSchema, + MetadataSchema, + MoneySchema, + PercentageSchema, +} from "@erp/core"; import { z } from "zod/v4"; +import { PaymentMethodRefSchema, TaxesBreakdownSchema } from "../../shared"; +import { + ProformaItemDetailSchema, + ProformaRecipientSummarySchema, + ProformaStatusSchema, +} from "../../shared/proforma"; + export const GetProformaByIdResponseSchema = z.object({ id: z.uuid(), company_id: z.uuid(), - is_proforma: z.string(), + is_proforma: z.boolean(), invoice_number: z.string(), - status: z.string(), - series: z.string(), + status: ProformaStatusSchema, + series: z.string().nullable(), - invoice_date: z.string(), - operation_date: z.string(), + invoice_date: IsoDateSchema, + operation_date: IsoDateSchema.nullable(), - reference: z.string(), - description: z.string(), - notes: z.string(), + reference: z.string().nullable(), + description: z.string().nullable(), + notes: z.string().nullable(), - language_code: z.string(), - currency_code: z.string(), + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, - customer_id: z.string(), - recipient: z.object({ - id: z.string(), - name: z.string(), - tin: z.string(), - street: z.string(), - street2: z.string(), - city: z.string(), - province: z.string(), - postal_code: z.string(), - country: z.string(), - }), + customer_id: z.uuid(), + recipient: ProformaRecipientSummarySchema, - taxes: z.array( - z.object({ - taxable_amount: MoneySchema, + linked_invoice_id: z.uuid().nullable(), - iva_code: z.string(), - iva_percentage: PercentageSchema, - iva_amount: MoneySchema, + taxes: TaxesBreakdownSchema, - rec_code: z.string(), - rec_percentage: PercentageSchema, - rec_amount: MoneySchema, - - retention_code: z.string(), - retention_percentage: PercentageSchema, - retention_amount: MoneySchema, - - taxes_amount: MoneySchema, - }) - ), - - payment_method: z - .object({ - payment_id: z.string(), - payment_description: z.string(), - }) - .optional(), + payment_method: PaymentMethodRefSchema.nullable(), subtotal_amount: MoneySchema, items_discount_amount: MoneySchema, - discount_percentage: PercentageSchema, - discount_amount: MoneySchema, + global_discount_percentage: PercentageSchema, + global_discount_amount: MoneySchema, taxable_amount: MoneySchema, iva_amount: MoneySchema, rec_amount: MoneySchema, @@ -71,43 +54,7 @@ export const GetProformaByIdResponseSchema = z.object({ taxes_amount: MoneySchema, total_amount: MoneySchema, - items: z.array( - z.object({ - id: z.uuid(), - is_valued: z.string(), - position: z.string(), - description: z.string(), - - quantity: QuantitySchema, - unit_amount: MoneySchema, - - subtotal_amount: MoneySchema, - - item_discount_percentage: PercentageSchema, - item_discount_amount: MoneySchema, - - global_discount_percentage: PercentageSchema, - global_discount_amount: MoneySchema, - - taxable_amount: MoneySchema, - - iva_code: z.string(), - iva_percentage: PercentageSchema, - iva_amount: MoneySchema, - - rec_code: z.string(), - rec_percentage: PercentageSchema, - rec_amount: MoneySchema, - - retention_code: z.string(), - retention_percentage: PercentageSchema, - retention_amount: MoneySchema, - - taxes_amount: MoneySchema, - - total_amount: MoneySchema, - }) - ), + items: z.array(ProformaItemDetailSchema), metadata: MetadataSchema.optional(), }); diff --git a/modules/customer-invoices/src/common/dto/response/proformas/list-proformas.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/list-proformas.response.dto.ts index 75d1432f..f283fb37 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/list-proformas.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/list-proformas.response.dto.ts @@ -1,42 +1,36 @@ import { - MetadataSchema, + CurrencyCodeSchema, + IsoDateSchema, MoneySchema, + LanguageCodeSchema, PercentageSchema, createPaginatedListSchema, } from "@erp/core"; import { z } from "zod/v4"; +import { ProformaRecipientSummarySchema, ProformaStatusSchema } from "../../shared/proforma"; + export const ListProformasResponseSchema = createPaginatedListSchema( z.object({ id: z.uuid(), company_id: z.uuid(), - is_proforma: z.string(), - - customer_id: z.string(), + is_proforma: z.boolean(), invoice_number: z.string(), - status: z.string(), - series: z.string(), + status: ProformaStatusSchema, + series: z.string().nullable(), - invoice_date: z.string(), - operation_date: z.string(), + invoice_date: IsoDateSchema, + operation_date: IsoDateSchema.nullable(), - language_code: z.string(), - currency_code: z.string(), + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, - reference: z.string(), - description: z.string(), + reference: z.string().nullable(), + description: z.string().nullable(), - recipient: z.object({ - tin: z.string(), - name: z.string(), - street: z.string(), - street2: z.string(), - city: z.string(), - postal_code: z.string(), - province: z.string(), - country: z.string(), - }), + customer_id: z.uuid(), + recipient: ProformaRecipientSummarySchema, subtotal_amount: MoneySchema, discount_percentage: PercentageSchema, @@ -45,9 +39,7 @@ export const ListProformasResponseSchema = createPaginatedListSchema( taxes_amount: MoneySchema, total_amount: MoneySchema, - linked_invoice_id: z.string(), - - metadata: MetadataSchema.optional(), + linked_invoice_id: z.uuid().nullable(), }) ); diff --git a/modules/customer-invoices/src/common/dto/shared/index.ts b/modules/customer-invoices/src/common/dto/shared/index.ts new file mode 100644 index 00000000..be9eb5ab --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/index.ts @@ -0,0 +1,5 @@ +export * from "./issued-invoices"; +export * from "./item-taxes-breakdown.dto"; +export * from "./payment-methof-ref.dto"; +export * from "./proforma"; +export * from "./taxes-breakdown.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts new file mode 100644 index 00000000..dae1bce1 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts @@ -0,0 +1,4 @@ +export * from "./issued-invoice-item-detail.dto"; +export * from "./issued-invoice-recipient-summary.dto"; +export * from "./issued-invoice-status.dto"; +export * from "./verifactu-record.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-item-detail.dto.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-item-detail.dto.ts new file mode 100644 index 00000000..81edc691 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-item-detail.dto.ts @@ -0,0 +1,28 @@ +import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core"; +import { z } from "zod/v4"; + +import { ItemTaxesBreakdownSchema } from "../item-taxes-breakdown.dto"; + +export const IssuedInvoiceItemDetailSchema = z.object({ + id: z.uuid(), + is_valued: z.boolean(), + position: z.number(), + description: z.string().nullable(), + + quantity: QuantitySchema, + unit_amount: MoneySchema, + + subtotal_amount: MoneySchema, + + item_discount_percentage: PercentageSchema, + item_discount_amount: MoneySchema, + + global_discount_percentage: PercentageSchema, + global_discount_amount: MoneySchema, + + ...ItemTaxesBreakdownSchema.shape, + + total_amount: MoneySchema, +}); + +export type IssuedInvoiceItemDetailDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-recipient-summary.dto.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-recipient-summary.dto.ts new file mode 100644 index 00000000..ff60c312 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-recipient-summary.dto.ts @@ -0,0 +1,16 @@ +import { CountryCodeSchema, PostalCodeSchema, TinSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const IssuedInvoiceRecipientSummarySchema = z.object({ + id: z.uuid(), + tin: TinSchema, + name: z.string(), + street: z.string().nullable(), + street2: z.string().nullable(), + city: z.string().nullable(), + province: z.string().nullable(), + postal_code: PostalCodeSchema.nullable(), + country: CountryCodeSchema.nullable(), +}); + +export type IssuedInvoiceRecipientSummaryDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-status.dto.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-status.dto.ts new file mode 100644 index 00000000..3e131270 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-status.dto.ts @@ -0,0 +1,5 @@ +import { z } from "zod/v4"; + +export const IssuedInvoiceStatusSchema = z.templateLiteral(["issued"]); + +export type IssuedInvoiceStatusDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/verifactu-record.dto.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/verifactu-record.dto.ts new file mode 100644 index 00000000..d0adf6c0 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/verifactu-record.dto.ts @@ -0,0 +1,13 @@ +import { URLSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const VerifactuRecordStatusSchema = z.enum(["pending", "verified", "rejected"]); + +export const VerifactuRecordSchema = z.object({ + status: VerifactuRecordStatusSchema, + url: URLSchema.nullable(), + qr_code: z.string().nullable(), +}); + +export type VerifactuRecordStatusDTO = z.infer; +export type VerifactuRecordDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/item-taxes-breakdown.dto.ts b/modules/customer-invoices/src/common/dto/shared/item-taxes-breakdown.dto.ts new file mode 100644 index 00000000..df8b70e2 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/item-taxes-breakdown.dto.ts @@ -0,0 +1,6 @@ +import type { z } from "zod/v4"; + +import { TaxesBreakdownSchema } from "./taxes-breakdown.dto"; + +export const ItemTaxesBreakdownSchema = TaxesBreakdownSchema; +export type ItemTaxesBreakdownDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/payment-methof-ref.dto.ts b/modules/customer-invoices/src/common/dto/shared/payment-methof-ref.dto.ts new file mode 100644 index 00000000..b48c2d08 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/payment-methof-ref.dto.ts @@ -0,0 +1,8 @@ +import { z } from "zod/v4"; + +export const PaymentMethodRefSchema = z.object({ + payment_id: z.uuid(), + payment_description: z.string(), +}); + +export type PaymentMethodRefDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/proforma/index.ts b/modules/customer-invoices/src/common/dto/shared/proforma/index.ts new file mode 100644 index 00000000..5e4f6629 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/proforma/index.ts @@ -0,0 +1,3 @@ +export * from "./proforma-item-detail.dto"; +export * from "./proforma-recipient-summary.dto"; +export * from "./proforma-status.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-item-detail.dto.ts b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-item-detail.dto.ts new file mode 100644 index 00000000..49077843 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-item-detail.dto.ts @@ -0,0 +1,28 @@ +import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core"; +import { z } from "zod/v4"; + +import { ItemTaxesBreakdownSchema } from "../item-taxes-breakdown.dto"; + +export const ProformaItemDetailSchema = z.object({ + id: z.uuid(), + is_valued: z.boolean(), + position: z.number(), + description: z.string().nullable(), + + quantity: QuantitySchema, + unit_amount: MoneySchema, + + subtotal_amount: MoneySchema, + + item_discount_percentage: PercentageSchema, + item_discount_amount: MoneySchema, + + global_discount_percentage: PercentageSchema, + global_discount_amount: MoneySchema, + + ...ItemTaxesBreakdownSchema.shape, + + total_amount: MoneySchema, +}); + +export type ProformaItemDetailDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-recipient-summary.dto.ts b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-recipient-summary.dto.ts new file mode 100644 index 00000000..efa1c007 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-recipient-summary.dto.ts @@ -0,0 +1,16 @@ +import { CountryCodeSchema, PostalCodeSchema, TinSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const ProformaRecipientSummarySchema = z.object({ + id: z.uuid().nullable(), + tin: TinSchema.nullable(), + name: z.string().nullable(), + street: z.string().nullable(), + street2: z.string().nullable(), + city: z.string().nullable(), + province: z.string().nullable(), + postal_code: PostalCodeSchema.nullable(), + country: CountryCodeSchema.nullable(), +}); + +export type ProformaRecipientSummaryDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-status.dto.ts b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-status.dto.ts new file mode 100644 index 00000000..3f98a5d1 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-status.dto.ts @@ -0,0 +1,5 @@ +import { z } from "zod/v4"; + +export const ProformaStatusSchema = z.enum(["draft", "sent", "approved", "rejected", "issued"]); + +export type ProformaStatusDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/taxes-breakdown.dto.ts b/modules/customer-invoices/src/common/dto/shared/taxes-breakdown.dto.ts new file mode 100644 index 00000000..45dff20c --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/taxes-breakdown.dto.ts @@ -0,0 +1,22 @@ +import { MoneySchema, PercentageSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const TaxesBreakdownSchema = z.object({ + taxable_amount: MoneySchema, + + iva_code: z.string(), + iva_percentage: PercentageSchema, + iva_amount: MoneySchema, + + rec_code: z.string().nullable(), + rec_percentage: PercentageSchema, + rec_amount: MoneySchema, + + retention_code: z.string().nullable(), + retention_percentage: PercentageSchema, + retention_amount: MoneySchema, + + taxes_amount: MoneySchema, +}); + +export type TaxesBreakdownDTO = z.infer; diff --git a/modules/customer-invoices/src/web/pages/create/customer-invoice.schema.ts b/modules/customer-invoices/src/web/pages/create/customer-invoice.schema.ts index 3c9ed456..c2b95457 100644 --- a/modules/customer-invoices/src/web/pages/create/customer-invoice.schema.ts +++ b/modules/customer-invoices/src/web/pages/create/customer-invoice.schema.ts @@ -6,7 +6,7 @@ export const CustomerInvoiceItemDataFormSchema = CreateProformaRequestSchema.ext subtotal_price: z.object({ amount: z.number().nullable(), scale: z.number(), - currency_code: z.string(), + currency_code: CurrencyCodeSchema, }), discount: z.object({ amount: z.number().nullable(), @@ -15,12 +15,12 @@ export const CustomerInvoiceItemDataFormSchema = CreateProformaRequestSchema.ext discount_price: z.object({ amount: z.number().nullable(), scale: z.number(), - currency_code: z.string(), + currency_code: CurrencyCodeSchema, }), before_tax_price: z.object({ amount: z.number().nullable(), scale: z.number(), - currency_code: z.string(), + currency_code: CurrencyCodeSchema, }), tax: z.object({ amount: z.number().nullable(), @@ -29,12 +29,12 @@ export const CustomerInvoiceItemDataFormSchema = CreateProformaRequestSchema.ext tax_price: z.object({ amount: z.number().nullable(), scale: z.number(), - currency_code: z.string(), + currency_code: CurrencyCodeSchema, }), total_price: z.object({ amount: z.number().nullable(), scale: z.number(), - currency_code: z.string(), + currency_code: CurrencyCodeSchema, }), }); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/index.ts b/modules/customer-invoices/src/web/proformas/update/ui/blocks/index.ts index f7577927..080f03d6 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/index.ts +++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/index.ts @@ -2,6 +2,4 @@ export * from "./items-editor"; export * from "./proforma-basic-info-fields"; export * from "./proforma-form-field-shell"; export * from "./proforma-header-fields-card"; -export * from "./proforma-header-form-grid"; -export * from "./proforma-section-card"; export * from "./selected-recipient"; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx index c3e4181f..0a9833ed 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx @@ -6,7 +6,6 @@ import { Button } from "@repo/shadcn-ui/components"; import { ProformaUpdateRecipientEditor } from "."; import { useTranslation } from "../../../../i18n"; -import type { Proforma } from "../../../shared/entities"; import type { UseUpdateProformaItemsControllerResult } from "../../controllers"; import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor"; @@ -14,7 +13,6 @@ import { ProformaUpdateItemsEditor } from "./proforma-update-items-editor"; type ProformaUpdateEditorProps = { formId: string; - proforma?: Proforma; isSubmitting: boolean; onSubmit: React.SubmitEventHandler; onReset: () => void; @@ -24,8 +22,6 @@ type ProformaUpdateEditorProps = { onCreateCustomerClick: () => void; itemsCtrl: UseUpdateProformaItemsControllerResult; - - className?: string; }; export const ProformaUpdateEditorForm = ({ @@ -37,7 +33,6 @@ export const ProformaUpdateEditorForm = ({ onChangeCustomerClick, onCreateCustomerClick, itemsCtrl, - className, }: ProformaUpdateEditorProps) => { const { t } = useTranslation(); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx index f5a356ff..e52064f6 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx @@ -1,7 +1,12 @@ -import { DatePickerField, SelectField, TextField } from "@repo/rdx-ui/components"; +import { + DatePickerField, + FormSectionCard, + FormSectionGrid, + SelectField, + TextField, +} from "@repo/rdx-ui/components"; import { useTranslation } from "../../../../i18n"; -import { ProformaHeaderFormGrid, ProformaSectionCard } from "../blocks"; interface ProformaUpdateHeaderEditorProps { disabled?: boolean; @@ -15,11 +20,11 @@ export const ProformaUpdateHeaderEditor = ({ const { t } = useTranslation(); return ( - - + - - + + ); }; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-items-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-items-editor.tsx index c1f603f3..682b2edb 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-items-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-items-editor.tsx @@ -1,10 +1,10 @@ +import { FormSectionCard } from "@repo/rdx-ui/components"; import { Button } from "@repo/shadcn-ui/components"; import { PlusIcon } from "lucide-react"; import type { ComponentProps } from "react"; import { useTranslation } from "../../../../i18n"; import type { UseUpdateProformaItemsControllerResult } from "../../controllers/use-update-proforma-items-controller"; -import { ProformaSectionCard } from "../blocks"; import { ProformaUpdateItemRowEditor } from "./proforma-update-item-row-editor"; import { ProformaUpdateItemsTotals } from "./proforma-update-items-totals"; @@ -21,7 +21,7 @@ export const ProformaUpdateItemsEditor = ({ const { t } = useTranslation(); return ( - @@ -57,6 +57,6 @@ export const ProformaUpdateItemsEditor = ({ - + ); }; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-recipient-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-recipient-editor.tsx index a3f3a489..1c65aaff 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-recipient-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-recipient-editor.tsx @@ -1,7 +1,7 @@ import type { CustomerSelectionOption } from "@erp/customers"; import { useTranslation } from "../../../../i18n"; -import { ProformaSectionCard, SelectedRecipientSummary } from "../blocks"; +import { FormSectionCard, SelectedRecipientSummary } from "../blocks"; interface ProformaUpdateRecipientEditorProps { disabled?: boolean; @@ -23,7 +23,7 @@ export const ProformaUpdateRecipientEditor = ({ const { t } = useTranslation(); return ( - + - + ); }; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx index 9bc8a0cd..b0e3bb29 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx @@ -43,17 +43,15 @@ export const ProformaUpdatePage = () => { if (!updateCtrl.proformaData) return ( - <> - - - - + + + ); return ( @@ -66,7 +64,7 @@ export const ProformaUpdatePage = () => { ; + ): Result; } export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper { - public map(dto: UpdateCustomerByIdRequestDTO, params: { companyId: UniqueID }) { + public map( + dto: UpdateCustomerByIdRequestDTO, + params: { companyId: UniqueID } + ): Result { + console.log("Mapping UpdateCustomerByIdRequestDTO to CustomerPatchProps:", dto); + try { const errors: ValidationErrorDetail[] = []; const customerPatchProps: CustomerPatchProps = {}; - console.log(dto); - - toPatchField(dto.reference).ifSet((reference) => { + toPatchField(dto.reference).ifSetOrNull((reference) => { customerPatchProps.reference = extractOrPushError( maybeFromNullableResult(reference, (value) => Name.create(value)), "reference", @@ -63,35 +69,23 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper { ); }); - toPatchField(dto.is_company).ifSet((is_company) => { - if (isNullishOrEmpty(is_company)) { - errors.push({ path: "is_company", message: "is_company cannot be empty" }); - return; - } - customerPatchProps.isCompany = extractOrPushError( - Result.ok(Boolean(is_company!)), - "is_company", - errors - ); + toPatchField(dto.is_company).ifSet((isCompany) => { + customerPatchProps.isCompany = isCompany; }); toPatchField(dto.name).ifSet((name) => { - if (isNullishOrEmpty(name)) { - errors.push({ path: "name", message: "Name cannot be empty" }); - return; - } - customerPatchProps.name = extractOrPushError(Name.create(name!), "name", errors); + customerPatchProps.name = extractOrPushError(Name.create(name), "name", errors); }); - toPatchField(dto.trade_name).ifSet((trade_name) => { + toPatchField(dto.trade_name).ifSetOrNull((tradeName) => { customerPatchProps.tradeName = extractOrPushError( - maybeFromNullableResult(trade_name, (value) => Name.create(value)), + maybeFromNullableResult(tradeName, (value) => Name.create(value)), "trade_name", errors ); }); - toPatchField(dto.tin).ifSet((tin) => { + toPatchField(dto.tin).ifSetOrNull((tin) => { customerPatchProps.tin = extractOrPushError( maybeFromNullableResult(tin, (value) => TINNumber.create(value)), "tin", @@ -99,71 +93,7 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper { ); }); - toPatchField(dto.email_primary).ifSet((email_primary) => { - customerPatchProps.emailPrimary = extractOrPushError( - maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)), - "email_primary", - errors - ); - }); - - toPatchField(dto.email_secondary).ifSet((email_secondary) => { - customerPatchProps.emailSecondary = extractOrPushError( - maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)), - "email_secondary", - errors - ); - }); - - toPatchField(dto.mobile_primary).ifSet((mobile_primary) => { - customerPatchProps.mobilePrimary = extractOrPushError( - maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)), - "mobile_primary", - errors - ); - }); - - toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => { - customerPatchProps.mobilePrimary = extractOrPushError( - maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)), - "mobile_secondary", - errors - ); - }); - - toPatchField(dto.phone_primary).ifSet((phone_primary) => { - customerPatchProps.phonePrimary = extractOrPushError( - maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)), - "phone_primary", - errors - ); - }); - - toPatchField(dto.phone_secondary).ifSet((phone_secondary) => { - customerPatchProps.phoneSecondary = extractOrPushError( - maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)), - "phone_secondary", - errors - ); - }); - - toPatchField(dto.fax).ifSet((fax) => { - customerPatchProps.fax = extractOrPushError( - maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)), - "fax", - errors - ); - }); - - toPatchField(dto.website).ifSet((website) => { - customerPatchProps.website = extractOrPushError( - maybeFromNullableResult(website, (value) => URLAddress.create(value)), - "website", - errors - ); - }); - - toPatchField(dto.legal_record).ifSet((legalRecord) => { + toPatchField(dto.legal_record).ifSetOrNull((legalRecord) => { customerPatchProps.legalRecord = extractOrPushError( maybeFromNullableResult(legalRecord, (value) => TextValue.create(value)), "legal_record", @@ -172,120 +102,186 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper { }); toPatchField(dto.language_code).ifSet((languageCode) => { - if (isNullishOrEmpty(languageCode)) { - errors.push({ path: "language_code", message: "Language code cannot be empty" }); - return; - } - customerPatchProps.languageCode = extractOrPushError( - LanguageCode.create(languageCode!), + LanguageCode.create(languageCode), "language_code", errors ); }); toPatchField(dto.currency_code).ifSet((currencyCode) => { - if (isNullishOrEmpty(currencyCode)) { - errors.push({ path: "currency_code", message: "Currency code cannot be empty" }); - return; - } - customerPatchProps.currencyCode = extractOrPushError( - CurrencyCode.create(currencyCode!), + CurrencyCode.create(currencyCode), "currency_code", errors ); }); - // Default taxes - const defaultTaxesCollection = new Collection(); - /*toPatchField(dto.default_taxes).ifSet((defaultTaxes) => { - customerPatchProps.defaultTaxes = defaultTaxesCollection; - - if (isNullishOrEmpty(defaultTaxes)) { - return; - } - - defaultTaxes!.forEach((taxCode, index) => { - const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors); - if (tax && customerPatchProps.defaultTaxes) { - customerPatchProps.defaultTaxes.add(tax); - } + /** + * Se mantiene la compatibilidad con el contrato actual. + * Cuando defaultTaxes tenga un contrato final estable, aquí conviene + * mapearlo a CustomerTaxes/Collection con su schema semántico real. + */ + toPatchField(dto.default_taxes).ifSet((_defaultTaxes) => { + errors.push({ + path: "default_taxes", + message: "default_taxes mapping is not implemented yet", }); - });*/ + }); - // PostalAddress - const addressPatchProps = this.mapPostalAddress(dto, errors); + const addressPatchProps = this.mapPostalAddress(dto.address, errors); if (addressPatchProps) { customerPatchProps.address = addressPatchProps; } + this.mapContact(dto.contact, customerPatchProps, errors); + if (errors.length > 0) { return Result.fail( new ValidationErrorCollection("Customer props mapping failed (update)", errors) ); } + console.log("Mapped CustomerPatchProps:", customerPatchProps); + return Result.ok(customerPatchProps); } catch (err: unknown) { - return Result.fail(new DomainError("Customer props mapping failed", { cause: err })); + return Result.fail(new DomainError("Customer props mapping failed (update)", { cause: err })); } } - public mapPostalAddress( - dto: UpdateCustomerByIdRequestDTO, + private mapPostalAddress( + dto: UpdateCustomerAddressPatchRequestDTO | undefined, errors: ValidationErrorDetail[] ): PostalAddressPatchProps | undefined { + if (!dto) { + return undefined; + } + const postalAddressPatchProps: PostalAddressPatchProps = {}; - toPatchField(dto.street).ifSet((street) => { + toPatchField(dto.street).ifSetOrNull((street) => { postalAddressPatchProps.street = extractOrPushError( maybeFromNullableResult(street, (value) => Street.create(value)), - "street", + "address.street", errors ); }); - toPatchField(dto.street2).ifSet((street2) => { + toPatchField(dto.street2).ifSetOrNull((street2) => { postalAddressPatchProps.street2 = extractOrPushError( maybeFromNullableResult(street2, (value) => Street.create(value)), - "street2", + "address.street2", errors ); }); - toPatchField(dto.city).ifSet((city) => { + toPatchField(dto.city).ifSetOrNull((city) => { postalAddressPatchProps.city = extractOrPushError( maybeFromNullableResult(city, (value) => City.create(value)), - "city", + "address.city", errors ); }); - toPatchField(dto.province).ifSet((province) => { + toPatchField(dto.province).ifSetOrNull((province) => { postalAddressPatchProps.province = extractOrPushError( maybeFromNullableResult(province, (value) => Province.create(value)), - "province", + "address.province", errors ); }); - toPatchField(dto.postal_code).ifSet((postalCode) => { + toPatchField(dto.postal_code).ifSetOrNull((postalCode) => { postalAddressPatchProps.postalCode = extractOrPushError( maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)), - "postal_code", + "address.postal_code", errors ); }); - toPatchField(dto.country).ifSet((country) => { + toPatchField(dto.country).ifSetOrNull((country) => { postalAddressPatchProps.country = extractOrPushError( maybeFromNullableResult(country, (value) => Country.create(value)), - "country", + "address.country", errors ); }); return Object.keys(postalAddressPatchProps).length > 0 ? postalAddressPatchProps : undefined; } + + private mapContact( + dto: UpdateCustomerContactPatchRequestDTO | undefined, + customerPatchProps: CustomerPatchProps, + errors: ValidationErrorDetail[] + ): void { + if (!dto) { + return; + } + + toPatchField(dto.email_primary).ifSetOrNull((emailPrimary) => { + customerPatchProps.emailPrimary = extractOrPushError( + maybeFromNullableResult(emailPrimary, (value) => EmailAddress.create(value)), + "contact.email_primary", + errors + ); + }); + + toPatchField(dto.email_secondary).ifSetOrNull((emailSecondary) => { + customerPatchProps.emailSecondary = extractOrPushError( + maybeFromNullableResult(emailSecondary, (value) => EmailAddress.create(value)), + "contact.email_secondary", + errors + ); + }); + + toPatchField(dto.phone_primary).ifSetOrNull((phonePrimary) => { + customerPatchProps.phonePrimary = extractOrPushError( + maybeFromNullableResult(phonePrimary, (value) => PhoneNumber.create(value)), + "contact.phone_primary", + errors + ); + }); + + toPatchField(dto.phone_secondary).ifSetOrNull((phoneSecondary) => { + customerPatchProps.phoneSecondary = extractOrPushError( + maybeFromNullableResult(phoneSecondary, (value) => PhoneNumber.create(value)), + "contact.phone_secondary", + errors + ); + }); + + toPatchField(dto.mobile_primary).ifSetOrNull((mobilePrimary) => { + customerPatchProps.mobilePrimary = extractOrPushError( + maybeFromNullableResult(mobilePrimary, (value) => PhoneNumber.create(value)), + "contact.mobile_primary", + errors + ); + }); + + toPatchField(dto.mobile_secondary).ifSetOrNull((mobileSecondary) => { + customerPatchProps.mobileSecondary = extractOrPushError( + maybeFromNullableResult(mobileSecondary, (value) => PhoneNumber.create(value)), + "contact.mobile_secondary", + errors + ); + }); + + toPatchField(dto.fax).ifSetOrNull((fax) => { + customerPatchProps.fax = extractOrPushError( + maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)), + "contact.fax", + errors + ); + }); + + toPatchField(dto.website).ifSetOrNull((website) => { + customerPatchProps.website = extractOrPushError( + maybeFromNullableResult(website, (value) => URLAddress.create(value)), + "contact.website", + errors + ); + }); + } } diff --git a/modules/customers/src/api/application/snapshot-builders/domain/customer-snapshot-builder.ts b/modules/customers/src/api/application/snapshot-builders/domain/customer-snapshot-builder.ts deleted file mode 100644 index b1dc7da2..00000000 --- a/modules/customers/src/api/application/snapshot-builders/domain/customer-snapshot-builder.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { ISnapshotBuilder } from "@erp/core/api"; -import { maybeToEmptyString } from "@repo/rdx-ddd"; - -import type { Customer } from "../../../domain"; - -import type { ICustomerFullSnapshot } from "./customer-snapshot.interface"; - -export interface ICustomerFullSnapshotBuilder - extends ISnapshotBuilder {} - -export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder { - toOutput(customer: Customer): ICustomerFullSnapshot { - const address = customer.address.toPrimitive(); - - return { - id: customer.id.toPrimitive(), - company_id: customer.companyId.toPrimitive(), - status: customer.isActive ? "active" : "inactive", - reference: maybeToEmptyString(customer.reference, (value) => value.toPrimitive()), - - is_company: String(customer.isCompany), - name: customer.name.toPrimitive(), - trade_name: maybeToEmptyString(customer.tradeName, (value) => value.toPrimitive()), - tin: maybeToEmptyString(customer.tin, (value) => value.toPrimitive()), - - street: maybeToEmptyString(address.street, (value) => value.toPrimitive()), - street2: maybeToEmptyString(address.street2, (value) => value.toPrimitive()), - city: maybeToEmptyString(address.city, (value) => value.toPrimitive()), - province: maybeToEmptyString(address.province, (value) => value.toPrimitive()), - postal_code: maybeToEmptyString(address.postalCode, (value) => value.toPrimitive()), - country: maybeToEmptyString(address.country, (value) => value.toPrimitive()), - - email_primary: maybeToEmptyString(customer.emailPrimary, (value) => value.toPrimitive()), - email_secondary: maybeToEmptyString(customer.emailSecondary, (value) => value.toPrimitive()), - - phone_primary: maybeToEmptyString(customer.phonePrimary, (value) => value.toPrimitive()), - phone_secondary: maybeToEmptyString(customer.phoneSecondary, (value) => value.toPrimitive()), - - mobile_primary: maybeToEmptyString(customer.mobilePrimary, (value) => value.toPrimitive()), - mobile_secondary: maybeToEmptyString(customer.mobileSecondary, (value) => - value.toPrimitive() - ), - - fax: maybeToEmptyString(customer.fax, (value) => value.toPrimitive()), - website: maybeToEmptyString(customer.website, (value) => value.toPrimitive()), - - legal_record: maybeToEmptyString(customer.legalRecord, (value) => value.toPrimitive()), - - default_taxes: customer.defaultTaxes.toKey(), - - language_code: customer.languageCode.toPrimitive(), - currency_code: customer.currencyCode.toPrimitive(), - - metadata: { - entity: "customer", - }, - }; - } -} diff --git a/modules/customers/src/api/application/snapshot-builders/domain/customer-snapshot.interface.ts b/modules/customers/src/api/application/snapshot-builders/domain/customer-snapshot.interface.ts deleted file mode 100644 index b7a3d2e3..00000000 --- a/modules/customers/src/api/application/snapshot-builders/domain/customer-snapshot.interface.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface ICustomerFullSnapshot { - id: string; - company_id: string; - status: string; - reference: string; - - is_company: string; - name: string; - trade_name: string; - tin: string; - - street: string; - street2: string; - city: string; - province: string; - postal_code: string; - country: string; - - email_primary: string; - email_secondary: string; - - phone_primary: string; - phone_secondary: string; - - mobile_primary: string; - mobile_secondary: string; - - fax: string; - website: string; - - legal_record: string; - - default_taxes: string; - - language_code: string; - currency_code: string; - - metadata?: Record; -} diff --git a/modules/customers/src/api/application/snapshot-builders/domain/index.ts b/modules/customers/src/api/application/snapshot-builders/domain/index.ts deleted file mode 100644 index ae35536c..00000000 --- a/modules/customers/src/api/application/snapshot-builders/domain/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./customer-snapshot.interface"; -export * from "./customer-snapshot-builder"; diff --git a/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts b/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts new file mode 100644 index 00000000..475a5a99 --- /dev/null +++ b/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts @@ -0,0 +1,60 @@ +import type { ISnapshotBuilder } from "@erp/core/api"; +import { toNullable } from "@repo/rdx-ddd"; + +import type { GetCustomerByIdResponseDTO } from "../../../../common"; +import type { Customer } from "../../../domain"; + +export interface ICustomerFullSnapshotBuilder + extends ISnapshotBuilder {} + +export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder { + toOutput(customer: Customer): GetCustomerByIdResponseDTO { + const address = customer.address.toPrimitive(); + + return { + id: customer.id.toPrimitive(), + company_id: customer.companyId.toPrimitive(), + status: customer.isActive ? "active" : "inactive", + reference: toNullable(customer.reference, (value) => value.toPrimitive()), + + is_company: customer.isCompany, + name: customer.name.toPrimitive(), + trade_name: toNullable(customer.tradeName, (value) => value.toPrimitive()), + tin: toNullable(customer.tin, (value) => value.toPrimitive()), + + address: { + street: toNullable(address.street, (value) => value.toPrimitive()), + street2: toNullable(address.street2, (value) => value.toPrimitive()), + city: toNullable(address.city, (value) => value.toPrimitive()), + province: toNullable(address.province, (value) => value.toPrimitive()), + postal_code: toNullable(address.postalCode, (value) => value.toPrimitive()), + country: toNullable(address.country, (value) => value.toPrimitive()), + }, + + contact: { + email_primary: toNullable(customer.emailPrimary, (value) => value.toPrimitive()), + email_secondary: toNullable(customer.emailSecondary, (value) => value.toPrimitive()), + + phone_primary: toNullable(customer.phonePrimary, (value) => value.toPrimitive()), + phone_secondary: toNullable(customer.phoneSecondary, (value) => value.toPrimitive()), + + mobile_primary: toNullable(customer.mobilePrimary, (value) => value.toPrimitive()), + mobile_secondary: toNullable(customer.mobileSecondary, (value) => value.toPrimitive()), + + fax: toNullable(customer.fax, (value) => value.toPrimitive()), + website: toNullable(customer.website, (value) => value.toPrimitive()), + }, + + legal_record: toNullable(customer.legalRecord, (value) => value.toPrimitive()), + + default_taxes: customer.defaultTaxes.toKey(), + + language_code: customer.languageCode.toPrimitive(), + currency_code: customer.currencyCode.toPrimitive(), + + metadata: { + entity: "customer", + }, + }; + } +} diff --git a/modules/customers/src/api/application/snapshot-builders/full/index.ts b/modules/customers/src/api/application/snapshot-builders/full/index.ts new file mode 100644 index 00000000..4971ca98 --- /dev/null +++ b/modules/customers/src/api/application/snapshot-builders/full/index.ts @@ -0,0 +1 @@ +export * from "./customer-full-snapshot-builder"; diff --git a/modules/customers/src/api/application/snapshot-builders/index.ts b/modules/customers/src/api/application/snapshot-builders/index.ts index b7726c46..3b83e1ff 100644 --- a/modules/customers/src/api/application/snapshot-builders/index.ts +++ b/modules/customers/src/api/application/snapshot-builders/index.ts @@ -1,2 +1,2 @@ -export * from "./domain"; +export * from "./full"; export * from "./summary"; diff --git a/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot-builder.ts b/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot-builder.ts index 31fa7731..8ec5b69c 100644 --- a/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot-builder.ts +++ b/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot-builder.ts @@ -1,49 +1,40 @@ import type { ISnapshotBuilder } from "@erp/core/api"; -import { maybeToEmptyString } from "@repo/rdx-ddd"; +import { toNullable } from "@repo/rdx-ddd"; +import type { CustomerSummaryDTO } from "../../../../common"; import type { CustomerSummary } from "../../models"; -import type { ICustomerSummarySnapshot } from "./customer-summary-snapshot.interface"; - export interface ICustomerSummarySnapshotBuilder - extends ISnapshotBuilder {} + extends ISnapshotBuilder {} export class CustomerSummarySnapshotBuilder implements ICustomerSummarySnapshotBuilder { - toOutput(customer: CustomerSummary): ICustomerSummarySnapshot { + toOutput(customer: CustomerSummary): CustomerSummaryDTO { const { address } = customer; return { id: customer.id.toString(), company_id: customer.companyId.toString(), status: customer.isActive ? "active" : "inactive", - reference: maybeToEmptyString(customer.reference, (value) => value.toString()), + reference: toNullable(customer.reference, (value) => value.toString()), - is_company: String(customer.isCompany), + is_company: customer.isCompany, name: customer.name.toString(), - trade_name: maybeToEmptyString(customer.tradeName, (value) => value.toString()), - tin: maybeToEmptyString(customer.tin, (value) => value.toString()), + trade_name: toNullable(customer.tradeName, (value) => value.toString()), + tin: toNullable(customer.tin, (value) => value.toString()), - street: maybeToEmptyString(address.street, (value) => value.toString()), - street2: maybeToEmptyString(address.street2, (value) => value.toString()), - city: maybeToEmptyString(address.city, (value) => value.toString()), - postal_code: maybeToEmptyString(address.postalCode, (value) => value.toString()), - province: maybeToEmptyString(address.province, (value) => value.toString()), - country: maybeToEmptyString(address.country, (value) => value.toString()), + address: { + street: toNullable(address.street, (value) => value.toString()), + city: toNullable(address.city, (value) => value.toString()), + postal_code: toNullable(address.postalCode, (value) => value.toString()), + province: toNullable(address.province, (value) => value.toString()), + country: toNullable(address.country, (value) => value.toString()), + }, - email_primary: maybeToEmptyString(customer.emailPrimary, (value) => value.toString()), - email_secondary: maybeToEmptyString(customer.emailSecondary, (value) => value.toString()), - - phone_primary: maybeToEmptyString(customer.phonePrimary, (value) => value.toString()), - phone_secondary: maybeToEmptyString(customer.phoneSecondary, (value) => value.toString()), - - mobile_primary: maybeToEmptyString(customer.mobilePrimary, (value) => value.toString()), - mobile_secondary: maybeToEmptyString(customer.mobileSecondary, (value) => value.toString()), - - fax: maybeToEmptyString(customer.fax, (value) => value.toString()), - website: maybeToEmptyString(customer.website, (value) => value.toString()), - - language_code: customer.languageCode.code, - currency_code: customer.currencyCode.code, + contact: { + email_primary: toNullable(customer.emailPrimary, (value) => value.toString()), + phone_primary: toNullable(customer.phonePrimary, (value) => value.toString()), + mobile_primary: toNullable(customer.mobilePrimary, (value) => value.toString()), + }, }; } } diff --git a/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot.interface.ts b/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot.interface.ts deleted file mode 100644 index 654e21ec..00000000 --- a/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot.interface.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type ICustomerSummarySnapshot = { - id: string; - company_id: string; - status: string; - reference: string; - - is_company: string; - name: string; - trade_name: string; - tin: string; - - street: string; - street2: string; - city: string; - postal_code: string; - province: string; - country: string; - - email_primary: string; - email_secondary: string; - - phone_primary: string; - phone_secondary: string; - - mobile_primary: string; - mobile_secondary: string; - - fax: string; - website: string; - - language_code: string; - currency_code: string; - - metadata?: Record; -}; diff --git a/modules/customers/src/api/application/snapshot-builders/summary/index.ts b/modules/customers/src/api/application/snapshot-builders/summary/index.ts index 1b83be43..d58a65b0 100644 --- a/modules/customers/src/api/application/snapshot-builders/summary/index.ts +++ b/modules/customers/src/api/application/snapshot-builders/summary/index.ts @@ -1,2 +1 @@ -export * from "./customer-summary-snapshot.interface"; export * from "./customer-summary-snapshot-builder"; diff --git a/modules/customers/src/api/domain/aggregates/customer.aggregate.ts b/modules/customers/src/api/domain/aggregates/customer.aggregate.ts index ec0268fb..41a5864a 100644 --- a/modules/customers/src/api/domain/aggregates/customer.aggregate.ts +++ b/modules/customers/src/api/domain/aggregates/customer.aggregate.ts @@ -93,12 +93,23 @@ export interface ICustomer { readonly currencyCode: CurrencyCode; } -type CustomerInternalProps = Omit & { - readonly address: PostalAddress; - readonly defaultTaxes: CustomerTaxes; -}; +export type CustomerInternalProps = Omit; export class Customer extends AggregateRoot implements ICustomer { + private _address: PostalAddress; + private _defaultTaxes: CustomerTaxes; + + protected constructor( + props: CustomerInternalProps, + address: PostalAddress, + defaultTaxes: CustomerTaxes, + id?: UniqueID + ) { + super(props, id); + this._address = address; + this._defaultTaxes = defaultTaxes; + } + static create(props: ICustomerCreateProps, id?: UniqueID): Result { const validationResult = Customer.validateCreateProps(props); @@ -108,30 +119,27 @@ export class Customer extends AggregateRoot implements IC const { address, defaultTaxes, ...internalProps } = props; + // Postal Address const postalAddressResult = PostalAddress.create(address); - if (postalAddressResult.isFailure) { return Result.fail(postalAddressResult.error); } + const postalAddress = postalAddressResult.data; - const taxes = CustomerTaxes.create(defaultTaxes); - if (taxes.isFailure) { - return Result.fail(taxes.error); + // Customer Taxes + const taxesResult = CustomerTaxes.create(defaultTaxes); + if (taxesResult.isFailure) { + return Result.fail(taxesResult.error); } - - const contact = new Customer( - { - ...internalProps, - defaultTaxes: taxes.data, - address: postalAddressResult.data, - }, - id - ); + const taxes = taxesResult.data; // Reglas de negocio / validaciones // ... // ... + // Crear instancia de Customer + const contact = new Customer(internalProps, postalAddress, taxes, id); + // Disparar eventos de dominio // ... // ... @@ -144,31 +152,56 @@ export class Customer extends AggregateRoot implements IC } // Rehidratación desde persistencia - static rehydrate(props: CustomerInternalProps, id: UniqueID): Customer { - return new Customer(props, id); + static rehydrate( + props: CustomerInternalProps, + address: PostalAddress, + defaultTaxes: CustomerTaxes, + id: UniqueID + ): Customer { + return new Customer(props, address, defaultTaxes, id); } public update(partialCustomer: CustomerPatchProps): Result { const { address: partialAddress, defaultTaxes: partialTaxes, ...rest } = partialCustomer; - Object.assign(this.props, rest); + const nextProps: CustomerInternalProps = { + ...this.props, + ...rest, + }; + + let nextAddress = this._address; + let nextDefaultTaxes = this._defaultTaxes; if (partialAddress) { - const addressResult = this.address.update(partialAddress); + const nextAddressResult = PostalAddress.create({ + ...this._address.getProps(), + ...partialAddress, + }); - if (addressResult.isFailure) { - return Result.fail(addressResult.error); + if (nextAddressResult.isFailure) { + return Result.fail(nextAddressResult.error); } + + nextAddress = nextAddressResult.data; } if (partialTaxes) { - const taxesResult = this.defaultTaxes.update(partialTaxes); + const nextTaxesResult = CustomerTaxes.create({ + ...this._defaultTaxes.getProps(), + ...partialTaxes, + }); - if (taxesResult.isFailure) { - return Result.fail(taxesResult.error); + if (nextTaxesResult.isFailure) { + return Result.fail(nextTaxesResult.error); } + + nextDefaultTaxes = nextTaxesResult.data; } + Object.assign(this.props, nextProps); + this._address = nextAddress; + this._defaultTaxes = nextDefaultTaxes; + return Result.ok(); } @@ -207,7 +240,7 @@ export class Customer extends AggregateRoot implements IC } public get address(): PostalAddress { - return this.props.address; + return this._address; } public get emailPrimary(): Maybe { @@ -247,7 +280,7 @@ export class Customer extends AggregateRoot implements IC } public get defaultTaxes(): CustomerTaxes { - return this.props.defaultTaxes; + return this._defaultTaxes; } public get languageCode(): LanguageCode { diff --git a/modules/customers/src/api/domain/value-objects/customer-taxes.vo.ts b/modules/customers/src/api/domain/value-objects/customer-taxes.vo.ts index ea5fd6aa..a5673c7c 100644 --- a/modules/customers/src/api/domain/value-objects/customer-taxes.vo.ts +++ b/modules/customers/src/api/domain/value-objects/customer-taxes.vo.ts @@ -24,11 +24,6 @@ export class CustomerTaxes extends ValueObject implements IC return Result.ok(new CustomerTaxes(props)); } - public update(partial: CustomerTaxesPatchProps): Result { - Object.assign(this.props, partial); - return Result.ok(); - } - /** * Reconstruye una instancia de CustomerTaxes a partir de una clave serializada. * Este método es la operación inversa de toKey(). diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts index 7e81600d..bccdc919 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts @@ -1 +1 @@ -export * from "./sequelize-customer.mapper"; +export * from "./sequelize-customer-domain.mapper"; diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer.mapper.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts similarity index 97% rename from modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer.mapper.ts rename to modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts index 96136bdc..3ea3cb45 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer.mapper.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts @@ -26,9 +26,9 @@ import { Result } from "@repo/rdx-utils"; import { Customer, + type CustomerInternalProps, CustomerStatus, CustomerTaxes, - type ICustomerCreateProps, } from "../../../../../domain"; import type { CustomerCreationAttributes, CustomerModel } from "../../models"; @@ -206,7 +206,7 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors)); } - const customerProps: ICustomerCreateProps = { + const customerProps: CustomerInternalProps = { companyId: companyId!, status: status!, reference: reference!, @@ -216,8 +216,6 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< tradeName: tradeName!, tin: tinNumber!, - address: postalAddress!, - emailPrimary: emailPrimaryAddress!, emailSecondary: emailSecondaryAddress!, phonePrimary: phonePrimaryNumber!, @@ -228,12 +226,17 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< website: website!, legalRecord: legalRecord!, - defaultTaxes: defaultTaxes!, languageCode: languageCode!, currencyCode: currencyCode!, }; - return Customer.create(customerProps, customerId); + const customer = Customer.rehydrate( + customerProps, + postalAddress!, + defaultTaxes!, + customerId! + ); + return Result.ok(customer); } catch (err: unknown) { return Result.fail(err as Error); } diff --git a/modules/customers/src/common/dto/request/update-customer-by-id.request.dto.ts b/modules/customers/src/common/dto/request/update-customer-by-id.request.dto.ts index ba86ac38..7d21e9d9 100644 --- a/modules/customers/src/common/dto/request/update-customer-by-id.request.dto.ts +++ b/modules/customers/src/common/dto/request/update-customer-by-id.request.dto.ts @@ -1,39 +1,67 @@ +import { + CountryCodeSchema, + EmailSchema, + LandPhoneSchema, + MobilePhoneSchema, + PostalCodeSchema, + TinSchema, + URLSchema, +} from "@erp/core"; import { z } from "zod/v4"; export const UpdateCustomerByIdParamsRequestSchema = z.object({ - customer_id: z.string(), + customer_id: z.uuid(), +}); + +export type UpdateCustomerByIdParamsRequestDTO = z.infer< + typeof UpdateCustomerByIdParamsRequestSchema +>; + +export const UpdateCustomerAddressPatchRequestSchema = z.object({ + street: z.string().nullable().optional(), + street2: z.string().nullable().optional(), + city: z.string().nullable().optional(), + province: z.string().nullable().optional(), + postal_code: PostalCodeSchema.nullable().optional(), + country: CountryCodeSchema.nullable().optional(), +}); + +export const UpdateCustomerContactPatchRequestSchema = z.object({ + email_primary: EmailSchema.nullable().optional(), + email_secondary: EmailSchema.nullable().optional(), + phone_primary: LandPhoneSchema.nullable().optional(), + phone_secondary: LandPhoneSchema.nullable().optional(), + mobile_primary: MobilePhoneSchema.nullable().optional(), + mobile_secondary: MobilePhoneSchema.nullable().optional(), + fax: LandPhoneSchema.nullable().optional(), + website: URLSchema.nullable().optional(), }); export const UpdateCustomerByIdRequestSchema = z.object({ - reference: z.string().optional(), + reference: z.string().nullable().optional(), - is_company: z.string().optional(), + is_company: z.boolean().optional(), name: z.string().optional(), - trade_name: z.string().optional(), - tin: z.string().optional(), - default_taxes: z.string().optional(), // completo (sustituye), o null => vaciar + trade_name: z.string().nullable().optional(), + tin: TinSchema.nullable().optional(), - street: z.string().optional(), - street2: z.string().optional(), - city: z.string().optional(), - province: z.string().optional(), - postal_code: z.string().optional(), - country: z.string().optional(), + default_taxes: z.string().nullable().optional(), - email_primary: z.string().optional(), - email_secondary: z.string().optional(), - phone_primary: z.string().optional(), - phone_secondary: z.string().optional(), - mobile_primary: z.string().optional(), - mobile_secondary: z.string().optional(), + address: UpdateCustomerAddressPatchRequestSchema.optional(), + contact: UpdateCustomerContactPatchRequestSchema.optional(), - fax: z.string().optional(), - website: z.string().optional(), - - legal_record: z.string().optional(), + legal_record: z.string().nullable().optional(), language_code: z.string().optional(), currency_code: z.string().optional(), }); -export type UpdateCustomerByIdRequestDTO = Partial>; +export type UpdateCustomerAddressPatchRequestDTO = z.infer< + typeof UpdateCustomerAddressPatchRequestSchema +>; + +export type UpdateCustomerContactPatchRequestDTO = z.infer< + typeof UpdateCustomerContactPatchRequestSchema +>; + +export type UpdateCustomerByIdRequestDTO = z.infer; diff --git a/modules/customers/src/common/dto/response/get-customer-by-id.response.dto.ts b/modules/customers/src/common/dto/response/get-customer-by-id.response.dto.ts index e07fd8cd..3132b3eb 100644 --- a/modules/customers/src/common/dto/response/get-customer-by-id.response.dto.ts +++ b/modules/customers/src/common/dto/response/get-customer-by-id.response.dto.ts @@ -1,39 +1,57 @@ -import { MetadataSchema } from "@erp/core"; +import { + CountryCodeSchema, + CurrencyCodeSchema, + EmailSchema, + LandPhoneSchema, + LanguageCodeSchema, + MetadataSchema, + MobilePhoneSchema, + PostalCodeSchema, + TinSchema, + URLSchema, +} from "@erp/core"; import { z } from "zod/v4"; +import { CustomerStatusSchema } from "../shared/customer-status.dto"; + export const GetCustomerByIdResponseSchema = z.object({ id: z.uuid(), company_id: z.uuid(), - reference: z.string(), + status: CustomerStatusSchema, + reference: z.string().nullable(), - is_company: z.string(), + is_company: z.boolean(), name: z.string(), - trade_name: z.string(), - tin: z.string(), + trade_name: z.string().nullable(), + tin: TinSchema.nullable(), - street: z.string(), - street2: z.string(), - city: z.string(), - province: z.string(), - postal_code: z.string(), - country: z.string(), + address: z.object({ + street: z.string().nullable(), + street2: z.string().nullable(), + city: z.string().nullable(), + province: z.string().nullable(), + postal_code: PostalCodeSchema.nullable(), + country: CountryCodeSchema.nullable(), + }), - email_primary: z.string(), - email_secondary: z.string(), - phone_primary: z.string(), - phone_secondary: z.string(), - mobile_primary: z.string(), - mobile_secondary: z.string(), + contact: z.object({ + email_primary: EmailSchema.nullable(), + email_secondary: EmailSchema.nullable(), + phone_primary: LandPhoneSchema.nullable(), + phone_secondary: LandPhoneSchema.nullable(), + mobile_primary: MobilePhoneSchema.nullable(), + mobile_secondary: MobilePhoneSchema.nullable(), - fax: z.string(), - website: z.string(), + fax: LandPhoneSchema.nullable(), + website: URLSchema.nullable(), + }), - legal_record: z.string(), + legal_record: z.string().nullable(), - default_taxes: z.string(), - status: z.string(), - language_code: z.string(), - currency_code: z.string(), + default_taxes: z.string().nullable(), + + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, metadata: MetadataSchema.optional(), }); diff --git a/modules/customers/src/common/dto/response/index.ts b/modules/customers/src/common/dto/response/index.ts index 2f08346a..310812f9 100644 --- a/modules/customers/src/common/dto/response/index.ts +++ b/modules/customers/src/common/dto/response/index.ts @@ -1,3 +1,5 @@ +export * from "../shared/customer-summary.dto"; + export * from "./create-customer.result.dto"; export * from "./get-customer-by-id.response.dto"; export * from "./list-customers.response.dto"; diff --git a/modules/customers/src/common/dto/response/list-customers.response.dto.ts b/modules/customers/src/common/dto/response/list-customers.response.dto.ts index b44d31b4..503fcc3a 100644 --- a/modules/customers/src/common/dto/response/list-customers.response.dto.ts +++ b/modules/customers/src/common/dto/response/list-customers.response.dto.ts @@ -1,39 +1,8 @@ -import { MetadataSchema, createPaginatedListSchema } from "@erp/core"; -import { z } from "zod/v4"; +import { createPaginatedListSchema } from "@erp/core"; +import type { z } from "zod/v4"; -export const ListCustomersResponseSchema = createPaginatedListSchema( - z.object({ - id: z.uuid(), - company_id: z.uuid(), - status: z.string(), - reference: z.string(), +import { CustomerSummarySchema } from "../shared"; - is_company: z.string(), - name: z.string(), - trade_name: z.string(), - tin: z.string(), - - street: z.string(), - street2: z.string(), - city: z.string(), - province: z.string(), - postal_code: z.string(), - country: z.string(), - - email_primary: z.string(), - email_secondary: z.string(), - phone_primary: z.string(), - phone_secondary: z.string(), - mobile_primary: z.string(), - mobile_secondary: z.string(), - fax: z.string(), - website: z.string(), - - language_code: z.string(), - currency_code: z.string(), - - metadata: MetadataSchema.optional(), - }) -); +export const ListCustomersResponseSchema = createPaginatedListSchema(CustomerSummarySchema); export type ListCustomersResponseDTO = z.infer; diff --git a/modules/customers/src/common/dto/shared/customer-status.dto.ts b/modules/customers/src/common/dto/shared/customer-status.dto.ts new file mode 100644 index 00000000..af42f1f0 --- /dev/null +++ b/modules/customers/src/common/dto/shared/customer-status.dto.ts @@ -0,0 +1,5 @@ +import { z } from "zod/v4"; + +export const CustomerStatusSchema = z.enum(["active", "inactive"]); + +export type CustomerStatusDTO = z.infer; diff --git a/modules/customers/src/common/dto/shared/customer-summary.dto.ts b/modules/customers/src/common/dto/shared/customer-summary.dto.ts new file mode 100644 index 00000000..7ee66102 --- /dev/null +++ b/modules/customers/src/common/dto/shared/customer-summary.dto.ts @@ -0,0 +1,41 @@ +import { + CountryCodeSchema, + EmailSchema, + LandPhoneSchema, + MobilePhoneSchema, + PostalCodeSchema, + TinSchema, +} from "@erp/core"; +import { z } from "zod/v4"; + +import { CustomerStatusSchema } from "./customer-status.dto"; + +export const CustomerSummarySchema = z.object({ + id: z.uuid(), + company_id: z.uuid(), + status: CustomerStatusSchema, + + reference: z.string().nullable(), + + is_company: z.boolean(), + + name: z.string(), + trade_name: z.string().nullable(), + tin: TinSchema.nullable(), + + address: z.object({ + street: z.string().nullable(), + city: z.string().nullable(), + province: z.string().nullable(), + postal_code: PostalCodeSchema.nullable(), + country: CountryCodeSchema.nullable(), + }), + + contact: z.object({ + email_primary: EmailSchema.nullable(), + phone_primary: LandPhoneSchema.nullable(), + mobile_primary: MobilePhoneSchema.nullable(), + }), +}); + +export type CustomerSummaryDTO = z.infer; diff --git a/modules/customers/src/common/dto/shared/index.ts b/modules/customers/src/common/dto/shared/index.ts new file mode 100644 index 00000000..806f8523 --- /dev/null +++ b/modules/customers/src/common/dto/shared/index.ts @@ -0,0 +1,2 @@ +export * from "./customer-status.dto"; +export * from "./customer-summary.dto"; diff --git a/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx b/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx index 6d21b530..1e32da7b 100644 --- a/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx +++ b/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx @@ -1,4 +1,3 @@ -import { safeHTTPUrl } from "@erp/core/client"; import { DataTableColumnHeader, InitialsAvatar } from "@repo/rdx-ui/components"; import { Badge, @@ -101,11 +100,11 @@ export function useCustomersGridColumns( /> ), accessorFn: (row) => - `${row.primaryEmail} ${row.primaryPhone} ${row.primaryMobile} ${row.website}`, + `${row.contact.primaryEmail} ${row.contact.primaryPhone} ${row.contact.primaryMobile}`, enableSorting: false, size: 140, minSize: 120, - cell: ({ row }) => , + cell: ({ row }) => , }, { id: "address", @@ -117,11 +116,11 @@ export function useCustomersGridColumns( /> ), accessorFn: (row) => - `${row.street} ${row.street2} ${row.city} ${row.postalCode} ${row.province} ${row.country}`, + `${row.address.street} ${row.address.city} ${row.address.postalCode} ${row.address.province} ${row.address.country}`, enableSorting: false, size: 140, minSize: 120, - cell: ({ row }) => , + cell: ({ row }) => , }, { id: "actions", @@ -138,7 +137,7 @@ export function useCustomersGridColumns( enableHiding: false, cell: ({ row }) => { const customer = row.original; - const { website, primaryEmail: email_primary } = customer; + const { primaryEmail } = customer.contact; return (
@@ -177,23 +176,17 @@ export function useCustomersGridColumns( - - window.open(safeHTTPUrl(website), "_blank", "noopener,noreferrer") - } - > - {t("pages.list.actions.visit_website")} - - - navigator.clipboard.writeText(email_primary)} - > - {t("pages.list.actions.copy_email")} - - - + {primaryEmail && ( + <> + navigator.clipboard.writeText(primaryEmail)} + > + {t("pages.list.actions.copy_email")} + + + + )} { - const fullAddress = `${adress.street}, ${adress.postalCode} ${adress.city}, ${adress.province}, ${adress.country}`; +const clean = (value?: string | null) => value?.trim() ?? ""; + +const join = (parts: Array, separator: string) => + parts.map(clean).filter(Boolean).join(separator); + +const getGoogleMapsUrl = (address: CustomerAddress) => { + const fullAddress = join( + [ + join([address.street, address.street2], ", "), + join([address.postalCode, address.city], " "), + address.province, + address.country, + ], + ", " + ); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`; }; export const AddressCell = ({ address }: { address: CustomerAddress }) => { const { t } = useTranslation(); - const line1 = [address.street, address.street2].filter(Boolean).join(", "); - const line2 = [address.postalCode, address.city].filter(Boolean).join(" "); - const line3 = [address.province, address.country].filter(Boolean).join(", "); + + const line1 = join([address.street, address.street2], ", "); + const line2 = join([address.postalCode, address.city], " "); + const line3 = join([address.province, address.country], ", "); + const line23 = join([line2, line3], " - "); + return (
{ title={t("components.address_cell.open_in_google_maps")} > +
-

{line1}

-

- {line2} - {line3} -

+ {line1 &&

{line1}

} + {line23 &&

{line23}

}
+
); }; - -/* - -
-
{line1 || -}
-
{[line2, line3].filter(Boolean).join(" • ")}
-
- - -*/ diff --git a/modules/customers/src/web/list/ui/components/contact-cell.tsx b/modules/customers/src/web/list/ui/components/contact-cell.tsx index 79f80704..a6fa75a6 100644 --- a/modules/customers/src/web/list/ui/components/contact-cell.tsx +++ b/modules/customers/src/web/list/ui/components/contact-cell.tsx @@ -1,67 +1,81 @@ -import type { CustomerListRow } from "@erp/customers/web/shared"; -import { MailIcon, PhoneIcon } from "lucide-react"; +import { GlobeIcon, MailIcon, PhoneIcon, PrinterIcon, SmartphoneIcon } from "lucide-react"; -export const ContactCell = ({ customer }: { customer: CustomerListRow }) => ( -
- {customer.primaryEmail && ( - - - {customer.primaryEmail} - - )} +interface ContactCellData { + primaryEmail?: string | null; + secondaryEmail?: string | null; + primaryPhone?: string | null; + secondaryPhone?: string | null; + primaryMobile?: string | null; + secondaryMobile?: string | null; + website?: string | null; + fax?: string | null; +} - {customer.secondaryEmail && ( - - - {customer.secondaryEmail} - - )} - {customer.primaryPhone && ( - - - {customer.primaryPhone} - - )} -
-); +const clean = (value?: string | null) => value?.trim() ?? ""; -/* +type ContactItem = { + value: string; + href: string; + icon: React.ReactNode; +}; -
- {customer.email_primary && ( -
- - - {customer.email_primary} +export const ContactCell = ({ contact }: { contact: ContactCellData }) => { + const emails = [contact.primaryEmail, contact.secondaryEmail].map(clean).filter(Boolean); + const mobiles = [contact.primaryMobile, contact.secondaryMobile].map(clean).filter(Boolean); + const phones = [contact.primaryPhone, contact.secondaryPhone].map(clean).filter(Boolean); + const website = clean(contact.website); + const fax = clean(contact.fax); + + const items: ContactItem[] = [ + ...emails.map((v) => ({ + value: v, + href: `mailto:${v}`, + icon: , + })), + + ...mobiles.map((v) => ({ + value: v, + href: `tel:${v}`, + icon: , + })), + + ...phones.map((v) => ({ + value: v, + href: `tel:${v}`, + icon: , + })), + + ...(website + ? [ + { + value: website, + href: website.startsWith("http") ? website : `https://${website}`, + icon: , + }, + ] + : []), + + ...(fax + ? [ + { + value: fax, + href: `tel:${fax}`, + icon: , + }, + ] + : []), + ]; + + if (items.length === 0) return null; + + return ( + - )} - - {customer.email_secondary && ( -
- - {customer.email_secondary} -
- )} - -
- - {customer.phone_primary || customer.mobile_primary || -} - {customer.phone_secondary && • {customer.phone_secondary}} - {customer.mobile_secondary && • {customer.mobile_secondary}} - {false} + ))}
- {false} -
- - - */ + ); +}; diff --git a/modules/customers/src/web/shared/adapters/get-customer-by-id.adapter.ts b/modules/customers/src/web/shared/adapters/get-customer-by-id.adapter.ts index 968a3095..38277041 100644 --- a/modules/customers/src/web/shared/adapters/get-customer-by-id.adapter.ts +++ b/modules/customers/src/web/shared/adapters/get-customer-by-id.adapter.ts @@ -12,8 +12,8 @@ import type { Customer } from "../entities"; export const GetCustomerByIdAdapter = { fromDTO(dto: GetCustomerByIdResult, context?: unknown): Customer { - const taxesAdapter = (taxes: string) => - taxes.split(";").filter((item) => item !== "#" && item.trim() !== ""); + const taxesAdapter = (taxes: string | null) => + taxes?.split(";").filter((item) => item !== "#" && item.trim() !== "") || null; const defaultTaxes = taxesAdapter(dto.default_taxes); @@ -22,27 +22,30 @@ export const GetCustomerByIdAdapter = { companyId: dto.company_id, reference: dto.reference, - isCompany: dto.is_company === "1", + isCompany: dto.is_company, name: dto.name, tradeName: dto.trade_name, tin: dto.tin, - street: dto.street, - street2: dto.street2, - city: dto.city, - province: dto.province, - postalCode: dto.postal_code, - country: dto.country, + address: { + street: dto.address.street, + street2: dto.address.street2, + city: dto.address.city, + province: dto.address.province, + postalCode: dto.address.postal_code, + country: dto.address.country, + }, + contact: { + primaryEmail: dto.contact.email_primary, + secondaryEmail: dto.contact.email_secondary, + primaryPhone: dto.contact.phone_primary, + secondaryPhone: dto.contact.phone_secondary, + primaryMobile: dto.contact.mobile_primary, + secondaryMobile: dto.contact.mobile_secondary, - primaryEmail: dto.email_primary, - secondaryEmail: dto.email_secondary, - primaryPhone: dto.phone_primary, - secondaryPhone: dto.phone_secondary, - primaryMobile: dto.mobile_primary, - secondaryMobile: dto.mobile_secondary, - - fax: dto.fax, - website: dto.website, + fax: dto.contact.fax, + website: dto.contact.website, + }, legalRecord: dto.legal_record, diff --git a/modules/customers/src/web/shared/adapters/list-customers.adapter.ts b/modules/customers/src/web/shared/adapters/list-customers.adapter.ts index 83451a13..16bea312 100644 --- a/modules/customers/src/web/shared/adapters/list-customers.adapter.ts +++ b/modules/customers/src/web/shared/adapters/list-customers.adapter.ts @@ -46,29 +46,23 @@ const ListCustomersRowAdapter = { status: rowDto.status, reference: rowDto.reference, - isCompany: rowDto.is_company === "1", + isCompany: rowDto.is_company, name: rowDto.name, tradeName: rowDto.trade_name, tin: rowDto.tin, - street: rowDto.street, - street2: rowDto.street2, - city: rowDto.city, - province: rowDto.province, - postalCode: rowDto.postal_code, - country: rowDto.country, - - primaryEmail: rowDto.email_primary, - secondaryEmail: rowDto.email_secondary, - primaryPhone: rowDto.phone_primary, - secondaryPhone: rowDto.phone_secondary, - primaryMobile: rowDto.mobile_primary, - secondaryMobile: rowDto.mobile_secondary, - fax: rowDto.fax, - website: rowDto.website, - - languageCode: rowDto.language_code, - currencyCode: rowDto.currency_code, + address: { + street: rowDto.address.street, + city: rowDto.address.city, + province: rowDto.address.province, + postalCode: rowDto.address.postal_code, + country: rowDto.address.country, + }, + contact: { + primaryEmail: rowDto.contact.email_primary, + primaryPhone: rowDto.contact.phone_primary, + primaryMobile: rowDto.contact.mobile_primary, + }, }; }, }; diff --git a/modules/customers/src/web/shared/entities/customer-list-row.entity.ts b/modules/customers/src/web/shared/entities/customer-list-row.entity.ts index b5f29688..71e7f75b 100644 --- a/modules/customers/src/web/shared/entities/customer-list-row.entity.ts +++ b/modules/customers/src/web/shared/entities/customer-list-row.entity.ts @@ -7,30 +7,26 @@ export interface CustomerListRow { id: string; companyId: string; status: string; - reference: string; + + reference: string | null; isCompany: boolean; + name: string; - tradeName: string; - tin: string; + tradeName: string | null; + tin: string | null; - street: string; - street2: string; - city: string; - province: string; - postalCode: string; - country: string; + address: { + street: string | null; + city: string | null; + province: string | null; + postalCode: string | null; + country: string | null; + }; - primaryEmail: string; - secondaryEmail: string; - primaryPhone: string; - secondaryPhone: string; - primaryMobile: string; - secondaryMobile: string; - - fax: string; - website: string; - - languageCode: string; - currencyCode: string; + contact: { + primaryEmail: string | null; + primaryPhone: string | null; + primaryMobile: string | null; + }; } diff --git a/modules/customers/src/web/shared/entities/customer.entity.ts b/modules/customers/src/web/shared/entities/customer.entity.ts index 818b9038..482e5d91 100644 --- a/modules/customers/src/web/shared/entities/customer.entity.ts +++ b/modules/customers/src/web/shared/entities/customer.entity.ts @@ -8,33 +8,37 @@ export interface Customer { id: string; companyId: string; status: string; - reference: string; + reference: string | null; isCompany: boolean; name: string; - tradeName: string; - tin: string; + tradeName: string | null; + tin: string | null; - street: string; - street2: string; - city: string; - province: string; - postalCode: string; - country: string; + address: { + street: string | null; + street2: string | null; + city: string | null; + province: string | null; + postalCode: string | null; + country: string | null; + }; - primaryEmail: string; - secondaryEmail: string; - primaryPhone: string; - secondaryPhone: string; - primaryMobile: string; - secondaryMobile: string; + contact: { + primaryEmail: string | null; + secondaryEmail: string | null; + primaryPhone: string | null; + secondaryPhone: string | null; + primaryMobile: string | null; + secondaryMobile: string | null; - fax: string; - website: string; + fax: string | null; + website: string | null; + }; - legalRecord: string; + legalRecord: string | null; - defaultTaxes: string[]; + defaultTaxes: string[] | null; languageCode: string; currencyCode: string; diff --git a/modules/customers/src/web/update/adapters/customer-to-customer-update-form.adapter.ts b/modules/customers/src/web/update/adapters/customer-to-customer-update-form.adapter.ts index b7d5efa0..ed5453ae 100644 --- a/modules/customers/src/web/update/adapters/customer-to-customer-update-form.adapter.ts +++ b/modules/customers/src/web/update/adapters/customer-to-customer-update-form.adapter.ts @@ -1,43 +1,45 @@ import type { Customer } from "../../shared"; -import type { CustomerUpdateForm } from "../entities"; - -/** +import { type CustomerUpdateForm, defaultCustomerUpdateForm } from "../entities"; /** * Mapea un cliente a un formulario de actualización de cliente. * + * Reglas: + * - normaliza null -> "" en campos de texto del formulario + * - normaliza null -> [] en colecciones del formulario + * - debe mantener el contrato del formulario (CustomerUpdateForm) + * aunque en el cliente no haya valores en ciertos campos (ej: dirección, contacto) + * * @param customer * @returns */ -export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => { - return { - reference: customer.reference ?? "", - isCompany: customer.isCompany, - name: customer.name ?? "", - tradeName: customer.tradeName ?? "", - tin: customer.tin ?? "", +export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => ({ + reference: customer.reference ?? "", + isCompany: customer.isCompany ?? defaultCustomerUpdateForm.isCompany, + name: customer.name, + tradeName: customer.tradeName ?? "", + tin: customer.tin ?? "", - defaultTaxes: customer.defaultTaxes ?? [], + defaultTaxes: customer.defaultTaxes ?? [], - street: customer.street ?? "", - street2: customer.street2 ?? "", - city: customer.city ?? "", - province: customer.province ?? "", - postalCode: customer.postalCode ?? "", - country: customer.country ?? "es", + street: customer.address.street ?? "", + street2: customer.address.street2 ?? "", + city: customer.address.city ?? "", + province: customer.address.province ?? "", + postalCode: customer.address.postalCode ?? "", + country: customer.address.country ?? defaultCustomerUpdateForm.country, - primaryEmail: customer.primaryEmail ?? "", - secondaryEmail: customer.secondaryEmail ?? "", - primaryPhone: customer.primaryPhone ?? "", - secondaryPhone: customer.secondaryPhone ?? "", - primaryMobile: customer.primaryMobile ?? "", - secondaryMobile: customer.secondaryMobile ?? "", + primaryEmail: customer.contact.primaryEmail ?? "", + secondaryEmail: customer.contact.secondaryEmail ?? "", + primaryPhone: customer.contact.primaryPhone ?? "", + secondaryPhone: customer.contact.secondaryPhone ?? "", + primaryMobile: customer.contact.primaryMobile ?? "", + secondaryMobile: customer.contact.secondaryMobile ?? "", - fax: customer.fax ?? "", - website: customer.website ?? "", + fax: customer.contact.fax ?? "", + website: customer.contact.website ?? "", - legalRecord: customer.legalRecord ?? "", + legalRecord: customer.legalRecord ?? "", - languageCode: customer.languageCode ?? "es", - currencyCode: customer.currencyCode ?? "EUR", - }; -}; + languageCode: customer.languageCode ?? defaultCustomerUpdateForm.languageCode, + currencyCode: customer.currencyCode ?? defaultCustomerUpdateForm.currencyCode, +}); diff --git a/modules/customers/src/web/update/controllers/use-customer-update.controller.ts b/modules/customers/src/web/update/controllers/use-customer-update.controller.ts index 7b353944..b6d12722 100644 --- a/modules/customers/src/web/update/controllers/use-customer-update.controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update.controller.ts @@ -1,4 +1,5 @@ import { useHookForm } from "@erp/core/hooks"; +import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd"; import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; import { useEffect, useId, useMemo } from "react"; import type { FieldErrors } from "react-hook-form"; @@ -95,6 +96,8 @@ export const useCustomerUpdateController = ( const patchData = buildCustomerUpdatePatch(formData, form.formState.dirtyFields); const params = buildUpdateCustomerByIdParams(customerId, patchData); + console.log("Enviando actualización con params:", params); + try { // Enviamos cambios al servidor const updated = await mutateAsync(params); @@ -114,9 +117,11 @@ export const useCustomerUpdateController = ( options?.onUpdated?.(updated); } catch (error: unknown) { - const normalizedError = - error instanceof Error ? error : new Error(t("pages.update.error.unknown")); + const normalizedError = isValidationErrorCollection(error) + ? (error as ValidationErrorCollection) + : (error as Error); + // Revertir form a datos anteriores (si los hay) form.reset( previousData ? mapCustomerToCustomerUpdateForm(previousData) : defaultCustomerUpdateForm, { keepDirty: false } diff --git a/modules/customers/src/web/update/entities/customer-update-form.schema.ts b/modules/customers/src/web/update/entities/customer-update-form.schema.ts index c6591ca5..4a9a7eb7 100644 --- a/modules/customers/src/web/update/entities/customer-update-form.schema.ts +++ b/modules/customers/src/web/update/entities/customer-update-form.schema.ts @@ -8,6 +8,7 @@ import { z } from "zod/v4"; * Reglas: * - no meter transformaciones silenciosas raras en el esquema (ej: .toUpperCase()) * - nombres en camelCase + * - acepta "" en campos opcionales * - tipos orientados a UI/form * - sin campos de solo lectura que no se editen * - sin shape DTO @@ -30,8 +31,8 @@ export const CustomerUpdateFormSchema = z.object({ postalCode: z.string(), country: z.string().min(1, "El país es obligatorio"), - primaryEmail: z.email("Email inválido"), - secondaryEmail: z.email("Email inválido"), + primaryEmail: z.email("Email inválido").or(z.literal("")), + secondaryEmail: z.email("Email inválido").or(z.literal("")), primaryPhone: z.string(), secondaryPhone: z.string(), @@ -39,7 +40,7 @@ export const CustomerUpdateFormSchema = z.object({ secondaryMobile: z.string(), fax: z.string(), - website: z.url("URL inválida"), + website: z.url("URL inválida").or(z.literal("")), legalRecord: z.string(), diff --git a/modules/customers/src/web/update/entities/customer-update-patch.entity.ts b/modules/customers/src/web/update/entities/customer-update-patch.entity.ts index 529586ed..3a2c02eb 100644 --- a/modules/customers/src/web/update/entities/customer-update-patch.entity.ts +++ b/modules/customers/src/web/update/entities/customer-update-patch.entity.ts @@ -1,17 +1,41 @@ -import type { CustomerUpdateForm } from "./customer-update-form.entity"; - /** - * CustomerUpdatePatch representa los cambios que se van a aplicar a un cliente. - * Se representa con las mismas propiedades que CustomerUpdateForm, - * pero todas ellas son opcionales. - * - * A la API solo hay que enviar los campos que han cambiado. + * CustomerUpdatePatch representa los cambios efectivos que se enviarán en update. * * Reglas: - * - debe ser un Partial de CustomerUpdateForm - * - no debe tener campos adicionales ni transformaciones - * - debe ser un shape orientado a la API, no a la UI ni al dominio - * - sin shape DTO, solo tipos simples y directos + * - `undefined` => no enviar + * - `null` => borrar explícitamente + * - `""` no se usa aquí para campos anulables; el builder lo convierte a null + * - mantiene naming camelCase porque sigue siendo contrato frontend */ -export type CustomerUpdatePatch = Partial; +export interface CustomerUpdatePatch { + reference?: string | null; + isCompany?: boolean; + name?: string; + tradeName?: string | null; + tin?: string | null; + + defaultTaxes?: string[]; + + street?: string | null; + street2?: string | null; + city?: string | null; + province?: string | null; + postalCode?: string | null; + country?: string | null; + + primaryEmail?: string | null; + secondaryEmail?: string | null; + primaryPhone?: string | null; + secondaryPhone?: string | null; + primaryMobile?: string | null; + secondaryMobile?: string | null; + + fax?: string | null; + website?: string | null; + + legalRecord?: string | null; + + languageCode?: string; + currencyCode?: string; +} diff --git a/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx b/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx index daaab3f1..6b768ec3 100644 --- a/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx @@ -1,53 +1,48 @@ -import { SelectField } from "@repo/rdx-ui/components"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLegend, - FieldSet, -} from "@repo/shadcn-ui/components"; +import { FormSectionCard, FormSectionGrid, SelectField } from "@repo/rdx-ui/components"; import { useTranslation } from "../../../i18n"; import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../../shared"; -interface CustomerAdditionalConfigFieldsProps { - className?: string; +interface CustomerAdditionalConfigEditorProps { + disabled?: boolean; + readOnly?: boolean; } -export const CustomerAdditionalConfigFields = ({ - className, - ...props -}: CustomerAdditionalConfigFieldsProps) => { +export const CustomerAdditionalConfigEditor = ({ + disabled = false, + readOnly = false, +}: CustomerAdditionalConfigEditorProps) => { const { t } = useTranslation(); return ( -
- {t("form_groups.preferences.title")} - {t("form_groups.preferences.description")} - - - - - - - - - -
+ + + + + + ); }; diff --git a/modules/customers/src/web/update/ui/editor/customer-address-editor.tsx b/modules/customers/src/web/update/ui/editor/customer-address-editor.tsx new file mode 100644 index 00000000..e14ec976 --- /dev/null +++ b/modules/customers/src/web/update/ui/editor/customer-address-editor.tsx @@ -0,0 +1,86 @@ +import { FormSectionCard, FormSectionGrid, SelectField, TextField } from "@repo/rdx-ui/components"; + +import { useTranslation } from "../../../i18n"; +import { COUNTRY_OPTIONS } from "../../../shared"; + +interface CustomerAddressEditorProps { + disabled?: boolean; + readOnly?: boolean; +} + +export const CustomerAddressEditor = ({ + disabled = false, + readOnly = false, +}: CustomerAddressEditorProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + ); +}; diff --git a/modules/customers/src/web/update/ui/editor/customer-address-fields.tsx b/modules/customers/src/web/update/ui/editor/customer-address-fields.tsx deleted file mode 100644 index 6f27d192..00000000 --- a/modules/customers/src/web/update/ui/editor/customer-address-fields.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { SelectField, TextField } from "@repo/rdx-ui/components"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLegend, - FieldSet, -} from "@repo/shadcn-ui/components"; - -import { useTranslation } from "../../../i18n"; -import { COUNTRY_OPTIONS } from "../../../shared"; - -interface CustomerAddressFieldsProps { - className?: string; -} - -export const CustomerAddressFields = ({ className, ...props }: CustomerAddressFieldsProps) => { - const { t } = useTranslation(); - - return ( -
- {t("form_groups.address.title")} - {t("form_groups.address.description")} - - - - - - - - - - - - - - - -
- ); -}; diff --git a/modules/customers/src/web/update/ui/editor/customer-basic-info-fields.tsx b/modules/customers/src/web/update/ui/editor/customer-basic-info-editor.tsx similarity index 78% rename from modules/customers/src/web/update/ui/editor/customer-basic-info-fields.tsx rename to modules/customers/src/web/update/ui/editor/customer-basic-info-editor.tsx index 99d25165..11a33ba0 100644 --- a/modules/customers/src/web/update/ui/editor/customer-basic-info-fields.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-basic-info-editor.tsx @@ -1,46 +1,51 @@ -import { FormFieldLabel, TextAreaField, TextField } from "@repo/rdx-ui/components"; +import { + FormFieldLabel, + FormSectionCard, + FormSectionGrid, + TextAreaField, + TextField, +} from "@repo/rdx-ui/components"; import { Field, FieldContent, FieldDescription, FieldError, - FieldGroup, FieldLabel, - FieldLegend, - FieldSet, RadioGroup, RadioGroupItem, } from "@repo/shadcn-ui/components"; -import { useEffect } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { useTranslation } from "../../../i18n"; -import type { CustomerUpdateForm } from "../../entities"; import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select"; -interface CustomerBasicInfoFieldsProps extends React.ComponentProps {} +interface CustomerBasicInfoEditorProps { + disabled?: boolean; + readOnly?: boolean; +} -export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicInfoFieldsProps) => { +export const CustomerBasicInfoEditor = ({ + disabled = false, + readOnly = false, +}: CustomerBasicInfoEditorProps) => { const { t } = useTranslation(); - const { control, setFocus } = useFormContext(); - - useEffect(() => { - setFocus("name"); - }, [setFocus]); + const { control } = useFormContext(); return ( -
- {t("form_groups.basic_info.title")} - {t("form_groups.basic_info.description")} - - + + @@ -48,12 +53,8 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn control={control} name="isCompany" render={({ field, fieldState }) => { - console.log(field.value); return ( - + {t("form_fields.customer_type.label")} { - console.log("Pongo ", value); field.onChange(value === "true"); }} required @@ -108,31 +108,37 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn /> - + - -
+ + ); }; diff --git a/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx b/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx index a477ce23..17bea23e 100644 --- a/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx @@ -1,13 +1,8 @@ -import { TextField } from "@repo/rdx-ui/components"; +import { FormSectionCard, FormSectionGrid, TextField } from "@repo/rdx-ui/components"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, - Field, - FieldDescription, - FieldGroup, - FieldLegend, - FieldSet, Separator, } from "@repo/shadcn-ui/components"; import { AtSignIcon, ChevronDown, GlobeIcon, PhoneIcon, SmartphoneIcon } from "lucide-react"; @@ -15,35 +10,43 @@ import { useState } from "react"; import { useTranslation } from "../../../i18n"; -interface CustomerContactFieldsProps { - className?: string; +interface CustomerContactEditorProps { + disabled?: boolean; + readOnly?: boolean; } -export const CustomerContactFields = ({ className, ...props }: CustomerContactFieldsProps) => { +export const CustomerContactEditor = ({ + disabled = false, + readOnly = false, +}: CustomerContactEditorProps) => { const { t } = useTranslation(); const [open, setOpen] = useState(true); return ( -
- {t("form_groups.contact_info.title")} - {t("form_groups.contact_info.description")} - + + } name="primaryEmail" placeholder={t("form_fields.email_primary.placeholder")} + readOnly={readOnly} required typePreset="email" /> } name="primaryPhone" placeholder={t("form_fields.phone_primary.placeholder")} + readOnly={readOnly} typePreset="phone" /> - - - + + + } name="secondaryEmail" placeholder={t("form_fields.email_secondary.placeholder")} + readOnly={readOnly} typePreset="email" /> } name="secondaryPhone" placeholder={t("form_fields.phone_secondary.placeholder")} + readOnly={readOnly} typePreset="phone" /> - - - + + + - - - - } - name="website" - placeholder={t("form_fields.website.placeholder")} - typePreset="text" - /> - - - - - + + + } + name="website" + placeholder={t("form_fields.website.placeholder")} + readOnly={readOnly} + typePreset="text" + /> + + - -
+ + ); }; diff --git a/modules/customers/src/web/update/ui/editor/customer-edit-form.tsx b/modules/customers/src/web/update/ui/editor/customer-edit-form.tsx index cc2c6a2c..ebd1919f 100644 --- a/modules/customers/src/web/update/ui/editor/customer-edit-form.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-edit-form.tsx @@ -1,29 +1,27 @@ -import { cn } from "@repo/shadcn-ui/lib/utils"; - -import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields"; -import { CustomerAddressFields } from "./customer-address-fields"; -import { CustomerBasicInfoFields } from "./customer-basic-info-fields"; -import { CustomerContactFields } from "./customer-contact-fields"; +import { CustomerAdditionalConfigEditor } from "./customer-additional-config-fields"; +import { CustomerAddressEditor } from "./customer-address-editor"; +import { CustomerBasicInfoEditor } from "./customer-basic-info-editor"; +import { CustomerContactEditor } from "./customer-contact-fields"; type CustomerUpdateEditorFormProps = { formId: string; - onSubmit: (event: React.FormEvent) => void; - className?: string; + isSubmitting: boolean; + onSubmit: React.SubmitEventHandler; + onReset: () => void; }; export const CustomerUpdateEditorForm = ({ formId, + isSubmitting, onSubmit, - className, + onReset, }: CustomerUpdateEditorFormProps) => { return (
-
- - - - -
+ + + + ); }; diff --git a/modules/customers/src/web/update/ui/pages/customer-update-page.tsx b/modules/customers/src/web/update/ui/pages/customer-update-page.tsx index 93f0a1fd..4e0f0ffe 100644 --- a/modules/customers/src/web/update/ui/pages/customer-update-page.tsx +++ b/modules/customers/src/web/update/ui/pages/customer-update-page.tsx @@ -1,6 +1,7 @@ import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components"; import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks"; import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; +import { Spinner } from "@repo/shadcn-ui/components"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "../../../i18n"; @@ -13,104 +14,88 @@ export const CustomerUpdatePage = () => { const { updateCtrl } = useCustomerUpdatePageController(); - const { - form, - formId, - onSubmit, - resetForm, - - customerData, - isLoading, - isLoadError, - loadError, - - isUpdating, - isUpdateError, - updateError, - } = updateCtrl; - - const isDirty = form.formState.isDirty; - - if (isLoading) { + if (updateCtrl.isLoading) { return ; } - if (isLoadError) { + if (updateCtrl.isLoadError) { return ( - <> - - + + -
- -
-
- +
+ +
+
); } - if (!customerData) + if (!updateCtrl.customerData) return ( - <> - - - - + + + ); return ( - - + + } title={t("pages.update.title")} /> - + {/* Alerta de error de actualización (si ha fallado el último intento) */} - {isUpdateError && ( + {updateCtrl.isUpdateError && ( )} - - - + {updateCtrl.isLoading && } + + {!updateCtrl.isLoading && ( + + + + )} ); diff --git a/modules/customers/src/web/update/utils/build-customer.update-patch.ts b/modules/customers/src/web/update/utils/build-customer.update-patch.ts index 65c05f82..5000db2e 100644 --- a/modules/customers/src/web/update/utils/build-customer.update-patch.ts +++ b/modules/customers/src/web/update/utils/build-customer.update-patch.ts @@ -1,11 +1,17 @@ -import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client"; +import { formHasAnyDirty } from "@erp/core/client"; import type { FieldNamesMarkedBoolean } from "react-hook-form"; import type { CustomerUpdateForm, CustomerUpdatePatch } from "../entities"; /** * Construye un parche de actualización de cliente a partir de los datos - * del formulario y los campos sucios. + * del formulario y los campos sucios (dirty fields). + * + * Reglas: + * - solo incluye campos dirty + * - `undefined` = no enviar + * - `null` = borrar explícitamente + * - `[]` = colección vacía explícita * * @param formData * @param dirtyFields @@ -19,5 +25,123 @@ export const buildCustomerUpdatePatch = ( if (!formHasAnyDirty(dirtyFields)) { return {}; } - return pickFormDirtyValues(formData, dirtyFields) as CustomerUpdatePatch; + + console.log("Campos sucios detectados:", dirtyFields); + + const patch: CustomerUpdatePatch = {}; + + if (dirtyFields.reference) { + patch.reference = toNullableText(formData.reference); + } + + if (dirtyFields.isCompany) { + patch.isCompany = formData.isCompany; + } + + if (dirtyFields.name) { + patch.name = toRequiredText(formData.name); + } + + if (dirtyFields.tradeName) { + patch.tradeName = toNullableText(formData.tradeName); + } + + if (dirtyFields.tin) { + patch.tin = toNullableText(formData.tin); + } + + if (dirtyFields.defaultTaxes) { + patch.defaultTaxes = formData.defaultTaxes; + } + + if (dirtyFields.street) { + patch.street = toNullableText(formData.street); + } + + if (dirtyFields.street2) { + patch.street2 = toNullableText(formData.street2); + } + + if (dirtyFields.city) { + patch.city = toNullableText(formData.city); + } + + if (dirtyFields.province) { + patch.province = toNullableText(formData.province); + } + + if (dirtyFields.postalCode) { + patch.postalCode = toNullableText(formData.postalCode); + } + + if (dirtyFields.country) { + patch.country = toNullableText(formData.country); + } + + if (dirtyFields.primaryEmail) { + patch.primaryEmail = toNullableText(formData.primaryEmail); + } + + if (dirtyFields.secondaryEmail) { + patch.secondaryEmail = toNullableText(formData.secondaryEmail); + } + + if (dirtyFields.primaryPhone) { + patch.primaryPhone = toNullableText(formData.primaryPhone); + } + + if (dirtyFields.secondaryPhone) { + patch.secondaryPhone = toNullableText(formData.secondaryPhone); + } + + if (dirtyFields.primaryMobile) { + patch.primaryMobile = toNullableText(formData.primaryMobile); + } + + if (dirtyFields.secondaryMobile) { + patch.secondaryMobile = toNullableText(formData.secondaryMobile); + } + + if (dirtyFields.fax) { + patch.fax = toNullableText(formData.fax); + } + + if (dirtyFields.website) { + patch.website = toNullableText(formData.website); + } + + if (dirtyFields.legalRecord) { + patch.legalRecord = toNullableText(formData.legalRecord); + } + + if (dirtyFields.languageCode) { + patch.languageCode = toRequiredText(formData.languageCode); + } + + if (dirtyFields.currencyCode) { + patch.currencyCode = toRequiredText(formData.currencyCode); + } + + return patch; +}; + +/** + * Convierte string del formulario a texto requerido. + * No devuelve null. + */ +const toRequiredText = (value: string): string => { + return value.trim(); +}; + +/** + * Convierte string del formulario a texto anulable. + * + * Reglas: + * - vacío o solo espacios => null + * - resto => trimmed string + */ +const toNullableText = (value: string): string | null => { + const trimmedValue = value.trim(); + + return trimmedValue === "" ? null : trimmedValue; }; diff --git a/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts b/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts index cd651c21..201fbfb2 100644 --- a/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts +++ b/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts @@ -25,6 +25,36 @@ export const buildUpdateCustomerByIdParams = ( ): UpdateCustomerByIdParams => { return { id, - data: patchData, - } as UpdateCustomerByIdParams; + data: { + reference: patchData.reference, + is_company: patchData.isCompany, + name: patchData.name, + trade_name: patchData.tradeName, + tin: patchData.tin, + + default_taxes: patchData.defaultTaxes?.toString(), + + address: { + street: patchData.street, + street2: patchData.street2, + city: patchData.city, + province: patchData.province, + postal_code: patchData.postalCode, + country: patchData.country, + }, + contact: { + email_primary: patchData.primaryEmail, + email_secondary: patchData.secondaryEmail, + phone_primary: patchData.primaryPhone, + phone_secondary: patchData.secondaryPhone, + mobile_primary: patchData.primaryMobile, + mobile_secondary: patchData.secondaryMobile, + fax: patchData.fax, + website: patchData.website, + }, + legal_record: patchData.legalRecord, + language_code: patchData.languageCode, + currency_code: patchData.currencyCode, + }, + } satisfies UpdateCustomerByIdParams; }; diff --git a/modules/customers/src/web/update/utils/index.ts b/modules/customers/src/web/update/utils/index.ts index ec596089..2fc76ab3 100644 --- a/modules/customers/src/web/update/utils/index.ts +++ b/modules/customers/src/web/update/utils/index.ts @@ -1,2 +1,3 @@ export * from "./build-customer.update-patch"; export * from "./build-update-customer-by-id-params"; +export * from "./focus-first-customer-update-error"; diff --git a/modules/supplier-invoices/src/api/infrastucture/jobs/supplier-invoice-processing.job.ts b/modules/supplier-invoices/src/api/infrastucture/jobs/supplier-invoice-processing.job.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/index.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/index.ts index 2b35b3f8..db68a478 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/index.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/index.ts @@ -1 +1 @@ -export * from "./sequelize-issued-invoice-domain.mapper"; +export * from "./sequelize-supplier-invoice-domain.mapper"; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-domain.mapper.ts similarity index 83% rename from modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts rename to modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-domain.mapper.ts index 3a9819d3..81d18b8b 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-domain.mapper.ts @@ -13,47 +13,31 @@ import { } from "@repo/rdx-ddd"; import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils"; -import { - type InternalIssuedInvoiceProps, - InvoiceAmount, - InvoiceNumber, - InvoicePaymentMethod, - InvoiceSerie, - InvoiceStatus, - IssuedInvoice, - IssuedInvoiceItems, - IssuedInvoiceTaxes, -} from "../../../../../../domain"; -import type { - CustomerInvoiceCreationAttributes, - CustomerInvoiceModel, -} from "../../../../../common"; +import { SupplierInvoice } from "../../../../../domain"; +import type { SupplierInvoiceCreationAttributes, SupplierInvoiceModel } from "../../models"; -import { SequelizeIssuedInvoiceItemDomainMapper } from "./sequelize-issued-invoice-item-domain.mapper"; -import { SequelizeIssuedInvoiceRecipientDomainMapper } from "./sequelize-issued-invoice-recipient-domain.mapper"; -import { SequelizeIssuedInvoiceTaxesDomainMapper } from "./sequelize-issued-invoice-taxes-domain.mapper"; -import { SequelizeIssuedInvoiceVerifactuDomainMapper } from "./sequelize-verifactu-record-domain.mapper"; +import { SequelizeSupplierInvoiceItemDomainMapper } from "./sequelize-supplier-invoice-item-domain.mapper"; +import { SequelizeSupplierInvoiceRecipientDomainMapper } from "./sequelize-supplier-invoice-recipient-domain.mapper"; +import { SequelizeSupplierInvoiceTaxesDomainMapper } from "./sequelize-supplier-invoice-taxes-domain.mapper"; -export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< - CustomerInvoiceModel, - CustomerInvoiceCreationAttributes, - IssuedInvoice +export class SequelizeSupplierInvoiceDomainMapper extends SequelizeDomainMapper< + SupplierInvoiceModel, + SupplierInvoiceCreationAttributes, + SupplierInvoice > { - private _itemsMapper: SequelizeIssuedInvoiceItemDomainMapper; - private _recipientMapper: SequelizeIssuedInvoiceRecipientDomainMapper; - private _taxesMapper: SequelizeIssuedInvoiceTaxesDomainMapper; - private _verifactuMapper: SequelizeIssuedInvoiceVerifactuDomainMapper; + private _itemsMapper: SequelizeSupplierInvoiceItemDomainMapper; + private _recipientMapper: SequelizeSupplierInvoiceRecipientDomainMapper; + private _taxesMapper: SequelizeSupplierInvoiceTaxesDomainMapper; constructor(params: MapperParamsType) { super(); - this._itemsMapper = new SequelizeIssuedInvoiceItemDomainMapper(params); // Instanciar el mapper de items - this._recipientMapper = new SequelizeIssuedInvoiceRecipientDomainMapper(); - this._taxesMapper = new SequelizeIssuedInvoiceTaxesDomainMapper(params); - this._verifactuMapper = new SequelizeIssuedInvoiceVerifactuDomainMapper(); - } + this._itemsMapper = new SequelizeSupplierInvoiceItemDomainMapper(params); // Instanciar el mapper de items + this._recipientMapper = new SequelizeSupplierInvoiceRecipientDomainMapper(); + this._taxesMapper = new SequelizeSupplierInvoiceTaxesDomainMapper(params); + - private _mapAttributesToDomain(raw: CustomerInvoiceModel, params?: MapperParamsType) { + private _mapAttributesToDomain(raw: SupplierInvoiceModel, params?: MapperParamsType) { const { errors } = params as { errors: ValidationErrorDetail[]; }; @@ -72,11 +56,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors); - const series = extractOrPushError( - maybeFromNullableResult(raw.series, (v) => InvoiceSerie.create(v)), - "series", - errors - ); + const series = extractOrPushError(InvoiceSerie.create(raw.series), "series", errors); const invoiceNumber = extractOrPushError( InvoiceNumber.create(raw.invoice_number), @@ -117,11 +97,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< errors ); - const description = extractOrPushError( - maybeFromNullableResult(raw.description, (value) => Result.ok(String(value))), - "description", - errors - ); + const description = extractOrPushError(raw.description, "description", errors); const notes = extractOrPushError( maybeFromNullableResult(raw.notes, (value) => TextValue.create(value)), @@ -286,9 +262,9 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< } public mapToDomain( - raw: CustomerInvoiceModel, + raw: SupplierInvoiceModel, params?: MapperParamsType - ): Result { + ): Result try { const errors: ValidationErrorDetail[] = []; @@ -334,20 +310,20 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< const verifactu = verifactuResult.data; - const items = IssuedInvoiceItems.create({ + const items = SupplierInvoiceItems.create({ items: itemsResults.data.getAll(), languageCode: attributes.languageCode!, currencyCode: attributes.currencyCode!, globalDiscountPercentage: attributes.globalDiscountPercentage!, }); - const taxes = IssuedInvoiceTaxes.create({ + const taxes = SupplierInvoiceTaxes.create({ taxes: taxesResults.data.getAll(), languageCode: attributes.languageCode!, currencyCode: attributes.currencyCode!, }); - const invoiceProps: InternalIssuedInvoiceProps = { + const invoiceProps: InternalSupplierInvoiceProps = { companyId: attributes.companyId!, proformaId: attributes.proformaId!, @@ -389,18 +365,17 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< }; const invoiceId = attributes.invoiceId!; - const invoice = IssuedInvoice.rehydrate(invoiceProps, items, invoiceId); + const invoice = SupplierInvoice.rehydrate(invoiceProps, items, invoiceId); return Result.ok(invoice); } catch (err: unknown) { return Result.fail(err as Error); } - } public mapToPersistence( - source: IssuedInvoice, + source: SupplierInvoice, params?: MapperParamsType - ): Result { + ): Result { const errors: ValidationErrorDetail[] = []; // 1) Items @@ -456,7 +431,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< const taxes = taxesResult.data; const verifactu = verifactuResult.data; - const invoiceValues: Partial = { + const invoiceValues: Partial = { // Identificación id: source.id.toPrimitive(), company_id: source.companyId.toPrimitive(), @@ -518,8 +493,8 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< verifactu, }; - return Result.ok( - invoiceValues as CustomerInvoiceCreationAttributes + return Result.ok( + invoiceValues as SupplierInvoiceCreationAttributes ); } } diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-item-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-item-domain.mapper.ts similarity index 94% rename from modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-item-domain.mapper.ts rename to modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-item-domain.mapper.ts index 17248d7c..2ff84101 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-item-domain.mapper.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-item-domain.mapper.ts @@ -18,23 +18,23 @@ import { import { Result } from "@repo/rdx-utils"; import { - type IIssuedInvoiceCreateProps, - type IIssuedInvoiceItemCreateProps, - type IssuedInvoice, - IssuedInvoiceItem, + type ISupplierInvoiceCreateProps, + type ISupplierInvoiceItemCreateProps, ItemAmount, ItemDescription, ItemQuantity, + type SupplierInvoice, + SupplierInvoiceItem, } from "../../../../../../domain"; import type { CustomerInvoiceItemCreationAttributes, CustomerInvoiceItemModel, } from "../../../../../common"; -export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMapper< +export class SequelizeSupplierInvoiceItemDomainMapper extends SequelizeDomainMapper< CustomerInvoiceItemModel, CustomerInvoiceItemCreationAttributes, - IssuedInvoiceItem + SupplierInvoiceItem > { private readonly taxCatalog!: JsonTaxCatalogProvider; @@ -45,7 +45,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe }; if (!taxCatalog) { - throw new Error('taxCatalog not defined ("SequelizeIssuedInvoiceItemDomainMapper")'); + throw new Error('taxCatalog not defined ("SequelizeSupplierInvoiceItemDomainMapper")'); } this.taxCatalog = taxCatalog; @@ -54,11 +54,11 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe private mapAttributesToDomain( raw: CustomerInvoiceItemModel, params?: MapperParamsType - ): Partial & { itemId?: UniqueID } { + ): Partial & { itemId?: UniqueID } { const { errors, index, attributes } = params as { index: number; errors: ValidationErrorDetail[]; - attributes: Partial; + attributes: Partial; }; const itemId = extractOrPushError( @@ -255,11 +255,11 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe public mapToDomain( source: CustomerInvoiceItemModel, params?: MapperParamsType - ): Result { + ): Result { const { errors, index } = params as { index: number; errors: ValidationErrorDetail[]; - attributes: Partial; + attributes: Partial; }; // 1) Valores escalares (atributos generales) @@ -274,7 +274,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe // 2) Construcción del elemento de dominio const itemId = attributes.itemId!; - const newItem = IssuedInvoiceItem.rehydrate( + const newItem = SupplierInvoiceItem.rehydrate( { description: attributes.description!, @@ -318,12 +318,12 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe } public mapToPersistence( - source: IssuedInvoiceItem, + source: SupplierInvoiceItem, params?: MapperParamsType ): Result { const { errors, index, parent } = params as { index: number; - parent: IssuedInvoice; + parent: SupplierInvoice; errors: ValidationErrorDetail[]; }; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-recipient-domain.mapper.ts similarity index 92% rename from modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts rename to modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-recipient-domain.mapper.ts index 3a0ab89c..bba52e0e 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-recipient-domain.mapper.ts @@ -16,24 +16,24 @@ import { import { Maybe, Result } from "@repo/rdx-utils"; import { - type IIssuedInvoiceCreateProps, + type ISupplierInvoiceCreateProps, InvoiceRecipient, - type IssuedInvoice, + type SupplierInvoice, } from "../../../../../../domain"; import type { CustomerInvoiceModel } from "../../../../../common"; -export class SequelizeIssuedInvoiceRecipientDomainMapper { +export class SequelizeSupplierInvoiceRecipientDomainMapper { public mapToDomain( source: CustomerInvoiceModel, params?: MapperParamsType ): Result, Error> { /** - * - Issued invoice -> snapshot de los datos (campos customer_*) + * - Supplier invoice -> snapshot de los datos (campos customer_*) */ const { errors, attributes } = params as { errors: ValidationErrorDetail[]; - attributes: Partial; + attributes: Partial; }; const _name = source.customer_name!; @@ -119,7 +119,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper { */ mapToPersistence(source: Maybe, params?: MapperParamsType) { const { errors, parent } = params as { - parent: IssuedInvoice; + parent: SupplierInvoice; errors: ValidationErrorDetail[]; }; @@ -129,7 +129,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper { if (!hasRecipient) { errors.push({ path: "recipient", - message: "[InvoiceRecipientDomainMapper] Issued customer invoice without recipient data", + message: "[InvoiceRecipientDomainMapper] Supplier customer invoice without recipient data", }); } diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-taxes-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-taxes-domain.mapper.ts similarity index 92% rename from modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-taxes-domain.mapper.ts rename to modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-taxes-domain.mapper.ts index d35130d5..995b7bf5 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-taxes-domain.mapper.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-supplier-invoice-taxes-domain.mapper.ts @@ -19,11 +19,11 @@ import { import { Result } from "@repo/rdx-utils"; import { - type IIssuedInvoiceCreateProps, + type ISupplierInvoiceCreateProps, InvoiceAmount, - type IssuedInvoice, - IssuedInvoiceTax, ItemAmount, + type SupplierInvoice, + SupplierInvoiceTax, } from "../../../../../../domain"; import type { CustomerInvoiceTaxCreationAttributes, @@ -42,10 +42,10 @@ import type { * * Cada fila = un impuesto agregado en toda la factura. */ -export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapper< +export class SequelizeSupplierInvoiceTaxesDomainMapper extends SequelizeDomainMapper< CustomerInvoiceTaxModel, CustomerInvoiceTaxCreationAttributes, - IssuedInvoiceTax + SupplierInvoiceTax > { private taxCatalog!: JsonTaxCatalogProvider; @@ -56,7 +56,7 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp }; if (!taxCatalog) { - throw new Error('taxCatalog not defined ("SequelizeIssuedInvoiceTaxesDomainMapper")'); + throw new Error('taxCatalog not defined ("SequelizeSupplierInvoiceTaxesDomainMapper")'); } this.taxCatalog = taxCatalog; @@ -65,11 +65,11 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp public mapToDomain( raw: CustomerInvoiceTaxModel, params?: MapperParamsType - ): Result { + ): Result { const { errors, index, attributes } = params as { index: number; errors: ValidationErrorDetail[]; - attributes: Partial; + attributes: Partial; }; const taxableAmount = extractOrPushError( @@ -154,7 +154,7 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp } // 2) Construcción del elemento de dominio - const createResult = IssuedInvoiceTax.create({ + const createResult = SupplierInvoiceTax.create({ taxableAmount: taxableAmount!, ivaCode: ivaCode!, @@ -184,11 +184,11 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp } public mapToPersistence( - source: IssuedInvoiceTax, + source: SupplierInvoiceTax, params?: MapperParamsType ): Result { const { errors, parent } = params as { - parent: IssuedInvoice; + parent: SupplierInvoice; errors: ValidationErrorDetail[]; }; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts deleted file mode 100644 index ff110981..00000000 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { MapperParamsType } from "@erp/core/api"; -import { SequelizeDomainMapper } from "@erp/core/api"; -import { - URLAddress, - UniqueID, - ValidationErrorCollection, - type ValidationErrorDetail, - extractOrPushError, - maybeFromNullableResult, - maybeToEmptyString, -} from "@repo/rdx-ddd"; -import { Maybe, Result } from "@repo/rdx-utils"; - -import { - type IIssuedInvoiceCreateProps, - type IssuedInvoice, - VerifactuRecord, - VerifactuRecordEstado, -} from "../../../../../../domain"; -import type { - VerifactuRecordCreationAttributes, - VerifactuRecordModel, -} from "../../../../../common"; - -export class SequelizeIssuedInvoiceVerifactuDomainMapper extends SequelizeDomainMapper< - VerifactuRecordModel, - VerifactuRecordCreationAttributes, - Maybe -> { - public mapToDomain( - source: VerifactuRecordModel, - params?: MapperParamsType - ): Result, Error> { - const { errors, attributes } = params as { - errors: ValidationErrorDetail[]; - attributes: Partial; - }; - - if (!source) { - return Result.ok(Maybe.none()); - } - - const recordId = extractOrPushError(UniqueID.create(source.id), "id", errors); - const estado = extractOrPushError( - VerifactuRecordEstado.create(source.estado), - "estado", - errors - ); - - const qr = extractOrPushError( - maybeFromNullableResult(source.qr, (value) => Result.ok(String(value))), - "qr", - errors - ); - - const url = extractOrPushError( - maybeFromNullableResult(source.url, (value) => URLAddress.create(value)), - "url", - errors - ); - - const uuid = extractOrPushError( - maybeFromNullableResult(source.uuid, (value) => Result.ok(String(value))), - "uuid", - errors - ); - - const operacion = extractOrPushError( - maybeFromNullableResult(source.operacion, (value) => Result.ok(String(value))), - "operacion", - errors - ); - - if (errors.length > 0) { - return Result.fail( - new ValidationErrorCollection("Verifactu record mapping failed [mapToDTO]", errors) - ); - } - - const createResult = VerifactuRecord.create( - { - estado: estado!, - qrCode: qr!, - url: url!, - uuid: uuid!, - operacion: operacion!, - }, - recordId! - ); - - if (createResult.isFailure) { - return Result.fail( - new ValidationErrorCollection("Invoice verifactu entity creation failed", [ - { path: "verifactu", message: createResult.error.message }, - ]) - ); - } - - return Result.ok(Maybe.some(createResult.data)); - } - - mapToPersistence( - source: Maybe, - params?: MapperParamsType - ): Result { - const { errors, parent } = params as { - parent: IssuedInvoice; - errors: ValidationErrorDetail[]; - }; - - if (source.isNone()) { - return Result.ok({ - id: UniqueID.generateNewID().toPrimitive(), - invoice_id: parent.id.toPrimitive(), - estado: VerifactuRecordEstado.createPendiente().toPrimitive(), - qr: "", - url: "", - uuid: "", - operacion: "", - }); - } - - const verifactu = source.unwrap(); - - return Result.ok({ - id: verifactu.id.toPrimitive(), - invoice_id: parent.id.toPrimitive(), - estado: verifactu.estado.toPrimitive(), - qr: maybeToEmptyString(verifactu.qrCode, (v) => v), - url: maybeToEmptyString(verifactu.url, (v) => v.toPrimitive()), - uuid: maybeToEmptyString(verifactu.uuid, (v) => v), - operacion: maybeToEmptyString(verifactu.operacion, (v) => v), - }); - } -} diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/index.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/index.ts index e5586981..c34f9740 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/index.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/index.ts @@ -1 +1 @@ -export * from "./sequelize-issued-invoice-summary.mapper"; +export * from "./sequelize-supplier-invoice-summary.mapper"; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-recipient-summary.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-supplier-invoice-recipient-summary.mapper.ts similarity index 100% rename from modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-recipient-summary.mapper.ts rename to modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-supplier-invoice-recipient-summary.mapper.ts diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-supplier-invoice-summary.mapper.ts similarity index 99% rename from modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts rename to modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-supplier-invoice-summary.mapper.ts index be6877bb..dfeaa1f0 100644 --- a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-supplier-invoice-summary.mapper.ts @@ -21,7 +21,7 @@ import { } from "../../../../../../domain"; import type { CustomerInvoiceModel } from "../../../../../common"; -import { SequelizeIssuedInvoiceRecipientListMapper } from "./sequelize-issued-invoice-recipient-summary.mapper"; +import { SequelizeIssuedInvoiceRecipientListMapper } from "./sequelize-supplier-invoice-recipient-summary.mapper"; import { SequelizeVerifactuRecordSummaryMapper } from "./sequelize-verifactu-record-summary.mapper"; export class SequelizeIssuedInvoiceSummaryMapper extends SequelizeQueryMapper< diff --git a/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts b/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts index de721ed0..31c5fcab 100644 --- a/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts +++ b/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts @@ -1,4 +1,10 @@ -import { MetadataSchema } from "@erp/core"; +import { + CountryCodeSchema, + CurrencyCodeSchema, + LanguageCodeSchema, + MetadataSchema, + PostalCodeSchema, +} from "@erp/core"; import { z } from "zod/v4"; export const GetSupplierByIdResponseSchema = z.object({ @@ -15,8 +21,8 @@ export const GetSupplierByIdResponseSchema = z.object({ street2: z.string(), city: z.string(), province: z.string(), - postal_code: z.string(), - country: z.string(), + postal_code: PostalCodeSchema, + country: CountryCodeSchema, email_primary: z.string(), email_secondary: z.string(), @@ -32,8 +38,8 @@ export const GetSupplierByIdResponseSchema = z.object({ default_taxes: z.array(z.string()), status: z.string(), - language_code: z.string(), - currency_code: z.string(), + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, metadata: MetadataSchema.optional(), }); diff --git a/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts b/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts index 134d3c0d..425f996b 100644 --- a/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts +++ b/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts @@ -1,4 +1,11 @@ -import { MetadataSchema, createPaginatedListSchema } from "@erp/core"; +import { + CountryCodeSchema, + CurrencyCodeSchema, + LanguageCodeSchema, + MetadataSchema, + PostalCodeSchema, + createPaginatedListSchema, +} from "@erp/core"; import { z } from "zod/v4"; export const ListSuppliersResponseSchema = createPaginatedListSchema( @@ -14,11 +21,11 @@ export const ListSuppliersResponseSchema = createPaginatedListSchema( tin: z.string(), street: z.string(), - street2: z.string(), + city: z.string(), province: z.string(), - postal_code: z.string(), - country: z.string(), + postal_code: PostalCodeSchema, + country: CountryCodeSchema, email_primary: z.string(), email_secondary: z.string(), @@ -29,8 +36,8 @@ export const ListSuppliersResponseSchema = createPaginatedListSchema( fax: z.string(), website: z.string(), - language_code: z.string(), - currency_code: z.string(), + language_code: LanguageCodeSchema, + currency_code: CurrencyCodeSchema, metadata: MetadataSchema.optional(), }) diff --git a/packages/rdx-ddd/src/errors/validation-error-collection.ts b/packages/rdx-ddd/src/errors/validation-error-collection.ts index 1f4d9b97..fa24fa85 100644 --- a/packages/rdx-ddd/src/errors/validation-error-collection.ts +++ b/packages/rdx-ddd/src/errors/validation-error-collection.ts @@ -16,7 +16,7 @@ */ import { BaseError } from "./base-error"; -import { DomainValidationError } from "./domain-validation-error"; +import type { DomainValidationError } from "./domain-validation-error"; export interface ValidationErrorDetail { /** Path del campo inválido, ej. "lines[1].unitPrice.amount" */ diff --git a/packages/rdx-ddd/src/helpers/normalizers.ts b/packages/rdx-ddd/src/helpers/normalizers.ts index 11ae68fe..7021bdb9 100644 --- a/packages/rdx-ddd/src/helpers/normalizers.ts +++ b/packages/rdx-ddd/src/helpers/normalizers.ts @@ -1,10 +1,87 @@ // Normalizadores y adaptadores DTO -> Maybe/VO -import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils"; +import { Maybe, Result, isMaybe, isNullishOrEmpty } from "@repo/rdx-utils"; import { MoneyValue, Percentage, Quantity } from "../value-objects"; -/** any | null | undefined -> Maybe usando validación */ +/** + * Aplica una transformación a un valor potencialmente nulo/indefinido. + * + * Si `input` es `null` o `undefined`, devuelve `null`. + * En caso contrario, aplica `map`. + * + * @typeParam T - Tipo de entrada no nulo. + * @typeParam R - Tipo de salida. + * @param input - Valor potencialmente nulo o indefinido. + * @param map - Función de transformación. + * @returns Resultado de `map` o `null` si el input es nulo. + */ +function mapNullable(input: T | null | undefined, map: (value: T) => R): R | null { + return input == null ? null : map(input); +} + +/** + * Serializa un `Maybe` a un valor de salida, aplicando una política explícita para `None`. + * + * - Si `Maybe` es `None`, devuelve `onNone`. + * - Si es `Some`, aplica `map`. + * + * Esta función centraliza la lógica común de serialización de `Maybe`. + * + * @typeParam T - Tipo interno del Maybe. + * @typeParam R - Tipo de salida. + * @param maybe - Instancia de `Maybe`. + * @param onNone - Valor a devolver cuando el Maybe es `None`. + * @param map - Función de transformación para el caso `Some`. + * @returns Valor serializado. + */ +function serializeMaybe(maybe: Maybe, onNone: R, map: (value: T) => R): R { + return !maybe || maybe.isNone() ? onNone : map(maybe.unwrap()); +} + +/** + * Convierte un valor (normal o `Maybe`) en un valor nullable (`R | null`). + * + * Soporta: + * - `null | undefined` → `null` + * - `T` → `map(T)` + * - `Maybe.none()` → `null` + * - `Maybe.some(T)` → `map(T)` + * + * @typeParam T - Tipo de entrada. + * @typeParam R - Tipo de salida. + * @param input - Valor o `Maybe` a transformar. + * @param map - Función de transformación. + * @returns Valor transformado o `null`. + */ +export function toNullable(input: null | undefined, map: (t: V) => R): null; +export function toNullable(input: Maybe, map: (t: V) => R): R | null; +export function toNullable(input: V, map: (t: V) => R): R; +export function toNullable( + input: V | Maybe | null | undefined, + map: (t: V) => R +): R | null { + if (isMaybe(input)) { + return maybeToNullable(input, map); + } + + return mapNullable(input, map); +} + +/** + * Convierte un valor nullable en un `Maybe` aplicando validación. + * + * - Si el valor es `null`, `undefined` o vacío → `Maybe.none()` + * - Si tiene valor → aplica `validate` + * - Success → `Maybe.some` + * - Failure → propaga error + * + * @typeParam T - Tipo validado. + * @typeParam S - Tipo de entrada. + * @param input - Valor a validar. + * @param validate - Función de validación. + * @returns Resultado con `Maybe`. + */ export function maybeFromNullableResult( input: S, validate: (raw: NonNullable) => Result @@ -18,9 +95,20 @@ export function maybeFromNullableResult( } /** - * Serializa un Maybe aplicando una política de objeto vacío. + * Serializa un `Maybe` a un objeto aplicando una política de objeto vacío. + * + * - `None` → `emptyObject` + * - `Some` → `map(value)` o `defaultSerializer(value)` * * @internal + * + * @typeParam T - Tipo interno del Maybe. + * @typeParam R - Tipo de salida (objeto de transporte). + * @param maybe - Instancia `Maybe`. + * @param emptyObject - Objeto vacío a devolver si es `None`. + * @param defaultSerializer - Serializador por defecto. + * @param map - Serializador opcional personalizado. + * @returns Objeto serializado. */ function maybeToEmptyObjectString( maybe: Maybe, @@ -28,31 +116,20 @@ function maybeToEmptyObjectString( defaultSerializer: (value: T) => R, map?: (value: T) => R ): R { - if (!maybe || maybe.isNone()) { - return emptyObject; - } - - const value = maybe.unwrap(); - return map ? map(value) : defaultSerializer(value); + return serializeMaybe(maybe, emptyObject, (value) => + map ? map(value) : defaultSerializer(value) + ); } /** - * Serializa un `Maybe` a un objeto de transporte normalizado. + * Serializa un `Maybe` a un objeto de transporte. * - * - Si el `Maybe` es `None`, devuelve un objeto vacío: - * `{ value: "", scale: "", currency_code: "" }` - * - Si es `Some`, aplica la función `map` si está declarada o - * aplica el método `toObjectString()` del `MoneyValue`. + * - `None` → objeto vacío de dinero + * - `Some` → `map` o `toObjectString()` * - * Motivación: - * - Evita devolver `null` en la capa de transporte. - * - Garantiza una estructura estable para consumidores (API / frontend). - * - Centraliza la política de normalización de importes opcionales. - * - * @typeParam T - Tipo interno del MoneyValue/Amount en dominio. - * @param maybe - Instancia `Maybe` que envuelve el Amount de dominio. - * @param map - Función que transforma el Amount de dominio al objeto de transporte. - * @returns Objeto normalizado de importe listo para transporte. + * @param maybe - Maybe de `MoneyValue`. + * @param map - Transformación opcional. + * @returns Objeto de dinero serializado. */ export function maybeToEmptyMoneyObjectString( maybe: Maybe, @@ -67,22 +144,14 @@ export function maybeToEmptyMoneyObjectString( } /** - * Serializa un `Maybe` a un objeto de transporte normalizado. + * Serializa un `Maybe` a un objeto de transporte. * - * - Si el `Maybe` es `None`, devuelve un objeto vacío: - * `{ value: "", scale: "" }` - * - Si es `Some`, aplica la función `map` si está declarada o - * aplica el método `toObjectString()` del `Percentage`. + * - `None` → objeto vacío + * - `Some` → `map` o `toObjectString()` * - * Motivación: - * - Evita devolver `null` en la capa de transporte. - * - Garantiza una estructura estable para consumidores (API / frontend). - * - Centraliza la política de normalización de porcentajes opcionales. - * - * @typeParam T - Tipo interno del Percentage en dominio. - * @param maybe - Instancia `Maybe` que envuelve el Percentage de dominio. - * @param map - Función que transforma el Percentage de dominio al objeto de transporte. - * @returns Objeto normalizado de porcentaje listo para transporte. + * @param maybe - Maybe de `Percentage`. + * @param map - Transformación opcional. + * @returns Objeto de porcentaje serializado. */ export function maybeToEmptyPercentageObjectString( maybe: Maybe, @@ -97,22 +166,14 @@ export function maybeToEmptyPercentageObjectString( } /** - * Serializa un `Maybe` a un objeto de transporte normalizado. + * Serializa un `Maybe` a un objeto de transporte. * - * - Si el `Maybe` es `None`, devuelve un objeto vacío: - * `{ value: "", scale: "" }` - * - Si es `Some`, aplica la función `map` si está declarada o - * aplica el método `toObjectString()` del `Quantity`. + * - `None` → objeto vacío + * - `Some` → `map` o `toObjectString()` * - * Motivación: - * - Evita devolver `null` en la capa de transporte. - * - Garantiza una estructura estable para consumidores (API / frontend). - * - Centraliza la política de normalización de cantidades opcionales. - * - * @typeParam T - Tipo interno del Quantity en dominio. - * @param maybe - Instancia `Maybe` que envuelve el Quantity de dominio. - * @param map - Función que transforma el Quantity de dominio al objeto de transporte. - * @returns Objeto normalizado de cantidad listo para transporte. + * @param maybe - Maybe de `Quantity`. + * @param map - Transformación opcional. + * @returns Objeto de cantidad serializado. */ export function maybeToEmptyQuantityObjectString( maybe: Maybe, @@ -126,28 +187,63 @@ export function maybeToEmptyQuantityObjectString( ); } -/** string | null | undefined -> Maybe (trim, vacío => None) */ +/** + * Convierte un string nullable en `Maybe`. + * + * - `null | undefined` → `None` + * - `""` o whitespace → `None` + * - valor válido → `Some(trimmed)` + * + * @param input - String de entrada. + * @returns Maybe del string. + */ export function maybeFromNullableOrEmptyString(input?: string | null): Maybe { - if (isNullishOrEmpty(input)) return Maybe.none(); - const t = input as string; - return t ? Maybe.some(t) : Maybe.none(); + if (input == null) return Maybe.none(); + + const trimmed = input.trim(); + return trimmed ? Maybe.some(trimmed) : Maybe.none(); } -/** Maybe -> null para transporte */ -export function maybeToNullable(m: Maybe, map: (t: T) => R): R | null { - if (!m || m.isNone()) return null; - return map(m.unwrap() as T); +/** + * Convierte un `Maybe` en `R | null`. + * + * - `None` → `null` + * - `Some` → `map(value)` + * + * @typeParam T - Tipo interno. + * @typeParam R - Tipo de salida. + * @param maybe - Instancia Maybe. + * @param map - Transformación. + * @returns Valor serializado o null. + */ +export function maybeToNullable(maybe: Maybe, map: (t: T) => R): R | null { + return serializeMaybe(maybe, null, map); } -export function maybeToNullableString(m: Maybe, map?: (t: T) => string): string | null { - if (!m || m.isNone()) return null; - const v = m.unwrap() as T; - return map ? String(map(v)) : String(v); +/** + * Convierte un `Maybe` en `string | null`. + * + * - `None` → `null` + * - `Some` → `map(value)` o `String(value)` + * + * @param maybe - Instancia Maybe. + * @param map - Transformación opcional. + * @returns String o null. + */ +export function maybeToNullableString(maybe: Maybe, map?: (t: T) => string): string | null { + return serializeMaybe(maybe, null, (value) => (map ? String(map(value)) : String(value))); } -/** Maybe -> "" para transporte */ -export function maybeToEmptyString(m: Maybe, map?: (t: T) => string): string { - if (!m || m.isNone()) return ""; - const v = m.unwrap() as T; - return map ? map(v) : String(v); +/** + * Convierte un `Maybe` en `string`. + * + * - `None` → `""` + * - `Some` → `map(value)` o `String(value)` + * + * @param maybe - Instancia Maybe. + * @param map - Transformación opcional. + * @returns String (nunca null). + */ +export function maybeToEmptyString(maybe: Maybe, map?: (t: T) => string): string { + return serializeMaybe(maybe, "", (value) => (map ? map(value) : String(value))); } diff --git a/packages/rdx-ddd/src/value-objects/postal-address.ts b/packages/rdx-ddd/src/value-objects/postal-address.ts index f2ed3109..238eb502 100644 --- a/packages/rdx-ddd/src/value-objects/postal-address.ts +++ b/packages/rdx-ddd/src/value-objects/postal-address.ts @@ -34,11 +34,6 @@ export class PostalAddress extends ValueObject { return Result.ok(new PostalAddress(values)); } - public update(partial: PostalAddressPatchProps): Result { - Object.assign(this.props, partial); - return Result.ok(); - } - get street(): Maybe { return this.props.street; } @@ -64,7 +59,14 @@ export class PostalAddress extends ValueObject { } getProps(): PostalAddressProps { - return this.props; + return { + street: this.street, + street2: this.street2, + city: this.city, + postalCode: this.postalCode, + province: this.province, + country: this.country, + }; } toPrimitive() { diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-section-card.tsx b/packages/rdx-ui/src/components/form/form-section-card.tsx similarity index 89% rename from modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-section-card.tsx rename to packages/rdx-ui/src/components/form/form-section-card.tsx index 5dddd452..6257d028 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-section-card.tsx +++ b/packages/rdx-ui/src/components/form/form-section-card.tsx @@ -4,19 +4,19 @@ import { FieldDescription, FieldLegend, FieldSet } from "@repo/shadcn-ui/compone import { cn } from "@repo/shadcn-ui/lib/utils"; import type { ReactNode } from "react"; -interface ProformaSectionCardProps { +interface FormSectionCardProps { title?: string; description?: string; children: ReactNode; className?: string; } -export const ProformaSectionCard = ({ +export const FormSectionCard = ({ title, description, children, className, -}: ProformaSectionCardProps) => { +}: FormSectionCardProps) => { return (
diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-header-form-grid.tsx b/packages/rdx-ui/src/components/form/form-section-grid.tsx similarity index 81% rename from modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-header-form-grid.tsx rename to packages/rdx-ui/src/components/form/form-section-grid.tsx index c8e217e9..c3b76588 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-header-form-grid.tsx +++ b/packages/rdx-ui/src/components/form/form-section-grid.tsx @@ -9,7 +9,7 @@ interface ProformaHeaderFormGridProps { className?: string; } -export const ProformaHeaderFormGrid = ({ children, className }: ProformaHeaderFormGridProps) => { +export const FormSectionGrid = ({ children, className }: ProformaHeaderFormGridProps) => { return ( {children} diff --git a/packages/rdx-ui/src/components/form/index.ts b/packages/rdx-ui/src/components/form/index.ts index b75561bf..aa45a4f9 100644 --- a/packages/rdx-ui/src/components/form/index.ts +++ b/packages/rdx-ui/src/components/form/index.ts @@ -2,6 +2,8 @@ export * from "./date-picker-field.tsx"; export * from "./date-picker-input-field/index.ts"; export * from "./decimal-field/index.ts"; export * from "./form-field-label.tsx"; +export * from "./form-section-card.tsx"; +export * from "./form-section-grid.tsx"; export * from "./multi-select-field.tsx"; export * from "./radio-group-field.tsx"; export * from "./select-field.tsx"; diff --git a/packages/rdx-ui/src/components/multi-select.tsx b/packages/rdx-ui/src/components/multi-select.tsx index 4a57ff3f..9830b753 100644 --- a/packages/rdx-ui/src/components/multi-select.tsx +++ b/packages/rdx-ui/src/components/multi-select.tsx @@ -182,7 +182,7 @@ export const MultiSelect = React.forwardRef const grouped = options.reduce>((acc, item) => { if (!acc[item.group || ""]) acc[item.group || ""] = []; - acc[item.group || ""].push(item); + acc[item.group ?? ""]?.push(item); return acc; }, {}); @@ -237,82 +237,84 @@ export const MultiSelect = React.forwardRef return ( - - - + )} + + } + /> setIsPopoverOpen(false)} + //onEscapeKeyDown={() => setIsPopoverOpen(false)} > @@ -337,7 +339,7 @@ export const MultiSelect = React.forwardRef {Object.keys(grouped).map((group) => { return ( - {grouped[group].map((option) => { + {grouped[group]?.map((option) => { const isSelected = selectedValues.includes(option.value); return ( { match(someFn: (value: T) => U, noneFn: () => U): U; } +export function isMaybe(input: unknown): input is Maybe { + return input instanceof Maybe; +} + export class Maybe implements IMaybe { private constructor(private readonly value?: T) {} @@ -26,6 +30,9 @@ export class Maybe implements IMaybe { } static some(value: T): Maybe { + if (value === undefined || value === null) { + throw new Error("Maybe.some cannot wrap null or undefined"); + } return new Maybe(value); } @@ -50,7 +57,7 @@ export class Maybe implements IMaybe { } getOrUndefined(): T | undefined { - return this.unwrap(); + return this.isSome() ? this.value : undefined; } map(fn: (value: T) => U): Maybe { diff --git a/packages/rdx-utils/src/helpers/patch-field.ts b/packages/rdx-utils/src/helpers/patch-field.ts index dfee1ee8..1afa3d6d 100644 --- a/packages/rdx-utils/src/helpers/patch-field.ts +++ b/packages/rdx-utils/src/helpers/patch-field.ts @@ -1,38 +1,165 @@ -import { isNullishOrEmpty } from "./utils"; -// Tri-estado para PATCH: unset | set(Some) | set(None) +/** + * Representa un campo PATCH con semántica tri-estado: + * + * - unset → el campo no fue enviado (no modificar) + * - set(value) → el campo fue enviado con valor (actualizar) + * - set(null) → el campo fue enviado explícitamente como null (borrar / desvincular) + * + * Este wrapper evita ambigüedades entre `undefined`, `null` y valores reales, + * alineándose con semántica PATCH en APIs. + * + * @typeParam T - Tipo del valor del campo (sin incluir null ni undefined) + * + * @example + * ```ts + * const field = toPatchField(dto.name); + * + * field + * .ifSet((value) => { ... }) // solo si viene con valor + * .ifNull(() => { ... }); // solo si viene explícitamente null + * ``` + */ export class PatchField { + /** + * Constructor privado. Se fuerza el uso de fábricas estáticas + * para garantizar consistencia en los estados. + */ private constructor( private readonly _isSet: boolean, - private readonly _value?: T | null + private readonly _isNull: boolean, + private readonly _value?: T ) {} + /** + * Crea un PatchField en estado "unset". + * + * Indica que el campo no fue enviado en el request. + * + * @returns Instancia en estado unset + */ static unset(): PatchField { - return new PatchField(false); + return new PatchField(false, false); } + /** + * Crea un PatchField en estado "set". + * + * - Si el valor es `null`, representa una eliminación explícita. + * - Si el valor es distinto de null, representa una asignación. + * + * @param value - Valor a asignar o null + * @returns Instancia en estado set + */ static set(value: T | null): PatchField { - return new PatchField(true, value); + if (value === null) return new PatchField(true, true); + return new PatchField(true, false, value); } + /** + * Indica si el campo fue enviado (ya sea con valor o con null). + */ get isSet(): boolean { return this._isSet; } - /** Devuelve el valor crudo (puede ser null) si isSet=true */ + /** + * Indica si el campo fue enviado explícitamente como null. + */ + get isNull(): boolean { + return this._isNull; + } + + /** + * Devuelve el valor del campo según su estado: + * + * - set(value) → devuelve value + * - set(null) → devuelve null + * - unset → devuelve undefined + * + * @returns Valor, null o undefined según estado + */ get value(): T | null | undefined { + if (this._isSet) { + return this._value; + } + + if (this._isNull) { + return null; + } + return this._value; } - /** Ejecuta una función solo si isSet=true */ - ifSet(fn: (v: T | null) => void): void { - if (this._isSet) fn(this._value ?? null); + /** + * Ejecuta la función solo si el campo está en estado set(value). + * + * No se ejecuta si: + * - unset + * - set(null) + * + * @param fn - Callback a ejecutar con el valor + * @returns this (permite chaining) + */ + ifSet(fn: (v: T) => void): PatchField { + if (this._isSet && !this._isNull) fn(this._value!); + return this; + } + + /** + * Ejecuta la función solo si el campo está en estado set(null). + * + * No se ejecuta si: + * - unset + * - set(value) + * + * @param fn - Callback a ejecutar + * @returns this (permite chaining) + */ + ifNull(fn: () => void): PatchField { + if (this._isSet && this._isNull) fn(); + return this; + } + + /** + * Ejecuta la función si el campo está en estado set(value) o set(null). + * + * No se ejecuta si: + * - unset + * + * @param fn - Callback que recibe el valor o null + * @returns this (permite chaining) + */ + ifSetOrNull(fn: (v: T | null) => void): PatchField { + if (this._isSet) fn(this._isNull ? null : this._value!); + return this; } } +/** + * Convierte un valor DTO (`T | null | undefined`) en un PatchField. + * + * Reglas: + * - undefined → unset + * - null → set(null) + * - valor → set(value) + * + * Este helper se usa típicamente en mappers de input para aplicar + * semántica PATCH de forma explícita y segura. + * + * @typeParam T - Tipo del valor original + * @param value - Valor procedente del DTO + * @returns PatchField correspondiente + * + * @example + * ```ts + * toPatchField(dto.email) + * .ifSet(v => updateEmail(v)) + * .ifNull(() => removeEmail()); + * ``` + */ export function toPatchField(value: T | null | undefined): PatchField { if (value === undefined) return PatchField.unset(); - // "" => null - if (isNullishOrEmpty(value)) return PatchField.set(null); + if (value === null) return PatchField.set(null); return PatchField.set(value as T); } diff --git a/packages/rdx-utils/tsconfig.json b/packages/rdx-utils/tsconfig.json index ef26e5af..6cb61051 100644 --- a/packages/rdx-utils/tsconfig.json +++ b/packages/rdx-utils/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "rootDir": "src" }, - "include": ["src"], + "include": ["src", "../../modules/core/src/api/application/mappers/patch-collector.ts"], "exclude": ["node_modules", "dist", "**/*.test.ts"] }