Muchos cambios
This commit is contained in:
parent
1ce77814b5
commit
53eb33376c
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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/
|
||||
└─ <bounded-context>/ (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*: `@<context>/dto/*` → `src/<context>/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/<context>/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` |
|
||||
@ -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<typeof ListIssuedInvoicesRequestSchema>;
|
||||
```
|
||||
## 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
|
||||
<action>-<resource>[...].request.dto.ts
|
||||
<action>-<resource>[...].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()`
|
||||
```plaintext
|
||||
list-proformas.request.dto.ts
|
||||
get-proforma-by-id.response.dto.ts
|
||||
update-proforma-by-id.request.dto.ts
|
||||
```
|
||||
|
||||
* `<resource>-summary.dto.ts` para vistas de resumen reutilizables de list.
|
||||
* `<resource>-detail.dto.ts` para vistas de detalle reutilizables de get.
|
||||
* `<action>-<resource>.response.dto.ts` para la response completa del endpoint.
|
||||
* `<resource>-summary.snapshot-builder.ts` para builders que materializan un item/resumen.
|
||||
* Evitar nombres como `I<Resource>Snapshot` si representan el mismo contrato que un DTO ya definido por schema.
|
||||
|
||||
## 3. Reglas de tipado
|
||||
|
||||
* 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:
|
||||
|
||||
* booleano -> `z.boolean()`
|
||||
* UUID -> `z.uuid()`
|
||||
* conjunto cerrado -> `z.enum([...])`
|
||||
* fecha/timestamp ISO -> schema específico
|
||||
* arrays -> `z.array(...)`
|
||||
* objetos -> `z.object(...)`
|
||||
|
||||
## 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.
|
||||
|
||||
## 5. Reglas para primitivas de negocio
|
||||
## 5. Fechas, porcentajes, importes, cantidades
|
||||
|
||||
* Un VO monetario/cuantitativo siempre debe tener una sola forma de transporte en todo el ERP.
|
||||
### 5.1. Amount, Money, Percentage y Quantity
|
||||
|
||||
## 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()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.2. Fechas y timestamps
|
||||
|
||||
Distinguir entre:
|
||||
|
||||
* fecha de calendario sin hora -> "2020-01-01"
|
||||
* timestamp con fecha y hora ->
|
||||
|
||||
```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"
|
||||
|
||||
```
|
||||
|
||||
Si `z.iso.date()` no está disponible o no os convence, crear schema propio.
|
||||
|
||||
---
|
||||
|
||||
## 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<z.infer<typeof UpdateProformaByIdRequestSchema>>;
|
||||
```
|
||||
|
||||
|
||||
## 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<typeof CustomerSummarySchema>;
|
||||
|
||||
export class CustomerSummarySnapshotBuilder
|
||||
implements ISnapshotBuilder<CustomerSummary, CustomerSummaryDTO> {
|
||||
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
|
||||
<resource>-summary.dto.ts
|
||||
<resource>-detail.dto.ts
|
||||
<action>-<resource>.response.dto.ts
|
||||
```
|
||||
|
||||
* `recipient`
|
||||
* `proforma-item`
|
||||
* `proforma-tax-breakdown`
|
||||
* `payment-method-ref`
|
||||
Builders:
|
||||
|
||||
```plaintext
|
||||
<resource>-summary.snapshot-builder.ts
|
||||
<resource>-detail.snapshot-builder.ts
|
||||
<action>-<resource>.response.snapshot-builder.ts
|
||||
```
|
||||
|
||||
Evitar:
|
||||
|
||||
```plaintext
|
||||
I<Resource>Snapshot ❌
|
||||
<Resource>Output ❌
|
||||
<Resource>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<string, unknown>[]` 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<typeof GetProformaByIdRequestSchema>;
|
||||
```
|
||||
|
||||
## 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<typeof ProformaTaxDTOSchema>;
|
||||
```
|
||||
|
||||
## 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<typeof UpdateProformaByIdRequestSchema>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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?
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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<ErrorToApiRule> = [
|
||||
{
|
||||
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);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
13
modules/core/src/common/dto/country-code.dto.ts
Normal file
13
modules/core/src/common/dto/country-code.dto.ts
Normal file
@ -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<typeof CountryCodeSchema>;
|
||||
13
modules/core/src/common/dto/currency-code.dto.ts
Normal file
13
modules/core/src/common/dto/currency-code.dto.ts
Normal file
@ -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<typeof CurrencyCodeSchema>;
|
||||
5
modules/core/src/common/dto/email.dto.ts
Normal file
5
modules/core/src/common/dto/email.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const EmailSchema = z.string().email();
|
||||
|
||||
export type EmailDTO = z.infer<typeof EmailSchema>;
|
||||
@ -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";
|
||||
|
||||
51
modules/core/src/common/dto/iso-date-dto.ts
Normal file
51
modules/core/src/common/dto/iso-date-dto.ts
Normal file
@ -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 });
|
||||
5
modules/core/src/common/dto/land-phone.dto.ts
Normal file
5
modules/core/src/common/dto/land-phone.dto.ts
Normal file
@ -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<typeof LandPhoneSchema>;
|
||||
13
modules/core/src/common/dto/language-code.dto.ts
Normal file
13
modules/core/src/common/dto/language-code.dto.ts
Normal file
@ -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<typeof LanguageCodeSchema>;
|
||||
5
modules/core/src/common/dto/mobile-phone.dto.ts
Normal file
5
modules/core/src/common/dto/mobile-phone.dto.ts
Normal file
@ -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<typeof MobilePhoneSchema>;
|
||||
21
modules/core/src/common/dto/postal-code.dto.ts
Normal file
21
modules/core/src/common/dto/postal-code.dto.ts
Normal file
@ -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<typeof PostalCodeSchema>;
|
||||
23
modules/core/src/common/dto/tin.dto.ts
Normal file
23
modules/core/src/common/dto/tin.dto.ts
Normal file
@ -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<typeof TinSchema>;
|
||||
7
modules/core/src/common/dto/url.dto.ts
Normal file
7
modules/core/src/common/dto/url.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const URLSchema = z.url({
|
||||
message: "Invalid URL format",
|
||||
});
|
||||
|
||||
export type URLDTO = z.infer<typeof URLSchema>;
|
||||
@ -38,4 +38,6 @@ export type IssuedInvoiceSummary = {
|
||||
totalAmount: InvoiceAmount;
|
||||
|
||||
verifactu: Maybe<VerifactuRecord>;
|
||||
|
||||
linkedProformaId: Maybe<UniqueID>;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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[];
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) => ({
|
||||
return {
|
||||
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: "",
|
||||
})
|
||||
);
|
||||
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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, string>;
|
||||
linked_proforma_id: string | null;
|
||||
}
|
||||
|
||||
@ -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<CreateProformaItemRequestDTO, "taxes">["taxes"],
|
||||
taxesDTO: NonNullable<CreateProformaRequestDTO["items"]>[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;
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ProformaItemRequestDTO, "taxes">["taxes"],
|
||||
private mapTaxesProps(
|
||||
taxesDTO: NonNullable<UpdateProformaByIdRequestDTO["items"]>[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;*/
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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<string, string>;
|
||||
metadata: Record<string, string> | null;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, string>;
|
||||
linked_invoice_id: string | null;
|
||||
}
|
||||
|
||||
@ -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<InvoiceRecipientProps> {
|
||||
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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,19 +32,19 @@ export interface IIssuedInvoiceCreateProps {
|
||||
companyId: UniqueID;
|
||||
status: InvoiceStatus;
|
||||
|
||||
proformaId: UniqueID; // <- id de la proforma padre en caso de issue
|
||||
linkedProformaId: Maybe<UniqueID>; // <- id de la proforma padre en caso de issue
|
||||
|
||||
series: Maybe<InvoiceSerie>;
|
||||
series: InvoiceSerie;
|
||||
invoiceNumber: InvoiceNumber;
|
||||
|
||||
invoiceDate: UtcDate;
|
||||
operationDate: Maybe<UtcDate>;
|
||||
|
||||
customerId: UniqueID;
|
||||
recipient: Maybe<InvoiceRecipient>;
|
||||
recipient: InvoiceRecipient;
|
||||
|
||||
reference: Maybe<string>;
|
||||
description: Maybe<string>;
|
||||
description: string;
|
||||
notes: Maybe<TextValue>;
|
||||
|
||||
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<UniqueID> {
|
||||
return this.props.linkedProformaId;
|
||||
}
|
||||
|
||||
public get status(): InvoiceStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
|
||||
public get series(): Maybe<InvoiceSerie> {
|
||||
public get series(): InvoiceSerie {
|
||||
return this.props.series;
|
||||
}
|
||||
|
||||
@ -206,7 +206,7 @@ export class IssuedInvoice
|
||||
return this.props.reference;
|
||||
}
|
||||
|
||||
public get description(): Maybe<string> {
|
||||
public get description(): string {
|
||||
return this.props.description;
|
||||
}
|
||||
|
||||
@ -214,7 +214,7 @@ export class IssuedInvoice
|
||||
return this.props.notes;
|
||||
}
|
||||
|
||||
public get recipient(): Maybe<InvoiceRecipient> {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -35,8 +35,8 @@ export interface IProformaCreateProps {
|
||||
companyId: UniqueID;
|
||||
status: InvoiceStatus;
|
||||
|
||||
series: Maybe<InvoiceSerie>;
|
||||
invoiceNumber: InvoiceNumber;
|
||||
series: Maybe<InvoiceSerie>;
|
||||
|
||||
invoiceDate: UtcDate;
|
||||
operationDate: Maybe<UtcDate>;
|
||||
@ -51,6 +51,8 @@ export interface IProformaCreateProps {
|
||||
languageCode: LanguageCode;
|
||||
currencyCode: CurrencyCode;
|
||||
|
||||
linkedInvoiceId: Maybe<UniqueID>;
|
||||
|
||||
paymentMethod: Maybe<InvoicePaymentMethod>;
|
||||
|
||||
items: IProformaItemCreateProps[];
|
||||
@ -100,17 +102,19 @@ export interface IProforma {
|
||||
|
||||
paymentMethod: Maybe<InvoicePaymentMethod>;
|
||||
|
||||
linkedInvoiceId: Maybe<UniqueID>;
|
||||
|
||||
items: IProformaItems; // <- Colección
|
||||
taxes(): Collection<IProformaTaxTotals>;
|
||||
totals(): IProformaTotals;
|
||||
}
|
||||
|
||||
export type InternalProformaProps = Omit<IProformaCreateProps, "items">;
|
||||
export type ProformaInternalProps = Omit<IProformaCreateProps, "items">;
|
||||
|
||||
export class Proforma extends AggregateRoot<InternalProformaProps> implements IProforma {
|
||||
private readonly _items: ProformaItems;
|
||||
export class Proforma extends AggregateRoot<ProformaInternalProps> 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<InternalProformaProps> 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<InternalProformaProps> implements IP
|
||||
public update(patchProps: ProformaPatchProps): Result<Proforma, Error> {
|
||||
const { items, ...otherProps } = patchProps;
|
||||
|
||||
const candidateProps: InternalProformaProps = {
|
||||
const candidateProps: ProformaInternalProps = {
|
||||
...this.props,
|
||||
...otherProps,
|
||||
};
|
||||
@ -261,6 +265,10 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
|
||||
return this.props.paymentMethod;
|
||||
}
|
||||
|
||||
public get linkedInvoiceId(): Maybe<UniqueID> {
|
||||
return this.props.linkedInvoiceId;
|
||||
}
|
||||
|
||||
public get languageCode(): LanguageCode {
|
||||
return this.props.languageCode;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<Maybe<InvoiceRecipient>, Error> {
|
||||
): Result<InvoiceRecipient, Error> {
|
||||
/**
|
||||
* - 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<InvoiceRecipient>, 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(),
|
||||
|
||||
@ -28,7 +28,7 @@ export class SequelizeIssuedInvoiceVerifactuDomainMapper extends SequelizeDomain
|
||||
Maybe<VerifactuRecord>
|
||||
> {
|
||||
public mapToDomain(
|
||||
source: VerifactuRecordModel,
|
||||
source: VerifactuRecordModel | null | undefined,
|
||||
params?: MapperParamsType
|
||||
): Result<Maybe<VerifactuRecord>, Error> {
|
||||
const { errors, attributes } = params as {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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!;
|
||||
|
||||
@ -58,7 +58,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
|
||||
const { errors, index, parent } = params as {
|
||||
index: number;
|
||||
errors: ValidationErrorDetail[];
|
||||
parent: Partial<ProformaCreateProps>;
|
||||
parent: Partial<IProformaCreateProps>;
|
||||
};
|
||||
|
||||
const itemId = extractOrPushError(
|
||||
|
||||
@ -303,6 +303,12 @@ export class ProformaRepository
|
||||
as: "taxes",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: CustomerInvoiceModel,
|
||||
as: "linked_invoice",
|
||||
required: false,
|
||||
attributes: ["id"],
|
||||
},
|
||||
],
|
||||
transaction,
|
||||
};
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./request";
|
||||
export * from "./response";
|
||||
export * from "./shared";
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@ -1,113 +1,60 @@
|
||||
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,
|
||||
taxable_amount: MoneySchema,
|
||||
iva_amount: MoneySchema,
|
||||
rec_amount: MoneySchema,
|
||||
retention_amount: MoneySchema,
|
||||
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(),
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
5
modules/customer-invoices/src/common/dto/shared/index.ts
Normal file
5
modules/customer-invoices/src/common/dto/shared/index.ts
Normal file
@ -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";
|
||||
@ -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";
|
||||
@ -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<typeof IssuedInvoiceItemDetailSchema>;
|
||||
@ -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<typeof IssuedInvoiceRecipientSummarySchema>;
|
||||
@ -0,0 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const IssuedInvoiceStatusSchema = z.templateLiteral(["issued"]);
|
||||
|
||||
export type IssuedInvoiceStatusDTO = z.infer<typeof IssuedInvoiceStatusSchema>;
|
||||
@ -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<typeof VerifactuRecordStatusSchema>;
|
||||
export type VerifactuRecordDTO = z.infer<typeof VerifactuRecordSchema>;
|
||||
@ -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<typeof ItemTaxesBreakdownSchema>;
|
||||
@ -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<typeof PaymentMethodRefSchema>;
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./proforma-item-detail.dto";
|
||||
export * from "./proforma-recipient-summary.dto";
|
||||
export * from "./proforma-status.dto";
|
||||
@ -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<typeof ProformaItemDetailSchema>;
|
||||
@ -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<typeof ProformaRecipientSummarySchema>;
|
||||
@ -0,0 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const ProformaStatusSchema = z.enum(["draft", "sent", "approved", "rejected", "issued"]);
|
||||
|
||||
export type ProformaStatusDTO = z.infer<typeof ProformaStatusSchema>;
|
||||
@ -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<typeof TaxesBreakdownSchema>;
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<HTMLFormElement>;
|
||||
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();
|
||||
|
||||
|
||||
@ -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 (
|
||||
<ProformaSectionCard
|
||||
<FormSectionCard
|
||||
description={t("form_groups.proformas.basic_info.description")}
|
||||
title={t("form_groups.proformas.basic_info.title")}
|
||||
>
|
||||
<ProformaHeaderFormGrid>
|
||||
<FormSectionGrid>
|
||||
<SelectField
|
||||
className="md:col-span-2"
|
||||
disabled={disabled}
|
||||
@ -67,7 +72,7 @@ export const ProformaUpdateHeaderEditor = ({
|
||||
placeholder={t("form_fields.proformas.description.placeholder")}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</ProformaHeaderFormGrid>
|
||||
</ProformaSectionCard>
|
||||
</FormSectionGrid>
|
||||
</FormSectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
<ProformaSectionCard
|
||||
<FormSectionCard
|
||||
description={t("form_groups.items.description")}
|
||||
title={t("form_groups.items.title")}
|
||||
>
|
||||
@ -57,6 +57,6 @@ export const ProformaUpdateItemsEditor = ({
|
||||
|
||||
<ProformaUpdateItemsTotals totals={itemsCtrl.totals} />
|
||||
</fieldset>
|
||||
</ProformaSectionCard>
|
||||
</FormSectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
<ProformaSectionCard title={t("form_groups.proformas.customer.title")}>
|
||||
<FormSectionCard title={t("form_groups.proformas.customer.title")}>
|
||||
<SelectedRecipientSummary
|
||||
disabled={disabled}
|
||||
onChangeClick={onChangeCustomerClick}
|
||||
@ -31,6 +31,6 @@ export const ProformaUpdateRecipientEditor = ({
|
||||
readOnly={readOnly}
|
||||
recipient={selectedCustomer}
|
||||
/>
|
||||
</ProformaSectionCard>
|
||||
</FormSectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
@ -43,7 +43,6 @@ export const ProformaUpdatePage = () => {
|
||||
|
||||
if (!updateCtrl.proformaData)
|
||||
return (
|
||||
<>
|
||||
<AppContent>
|
||||
<NotFoundCard
|
||||
message={t(
|
||||
@ -53,7 +52,6 @@ export const ProformaUpdatePage = () => {
|
||||
title={t("pages.proformas.update.not_found_title", "Proforma no encontrada")}
|
||||
/>
|
||||
</AppContent>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -66,7 +64,7 @@ export const ProformaUpdatePage = () => {
|
||||
<UpdateCommitButtonGroup
|
||||
cancel={{
|
||||
formId: updateCtrl.formId,
|
||||
to: "/customers/list",
|
||||
to: "/proformas/list",
|
||||
disabled: updateCtrl.isUpdating,
|
||||
}}
|
||||
disabled={updateCtrl.isUpdating}
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
Province,
|
||||
Street,
|
||||
TINNumber,
|
||||
type TaxCode,
|
||||
TextValue,
|
||||
URLAddress,
|
||||
type UniqueID,
|
||||
@ -21,9 +20,13 @@ import {
|
||||
extractOrPushError,
|
||||
maybeFromNullableResult,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Collection, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
|
||||
import { Result, toPatchField } from "@repo/rdx-utils";
|
||||
|
||||
import type { UpdateCustomerByIdRequestDTO } from "../../../common";
|
||||
import type {
|
||||
UpdateCustomerAddressPatchRequestDTO,
|
||||
UpdateCustomerByIdRequestDTO,
|
||||
UpdateCustomerContactPatchRequestDTO,
|
||||
} from "../../../common";
|
||||
import type { CustomerPatchProps } from "../../domain";
|
||||
|
||||
/**
|
||||
@ -44,18 +47,21 @@ export interface IUpdateCustomerInputMapper {
|
||||
map(
|
||||
dto: UpdateCustomerByIdRequestDTO,
|
||||
params: { companyId: UniqueID }
|
||||
): Result<CustomerPatchProps>;
|
||||
): Result<CustomerPatchProps, Error>;
|
||||
}
|
||||
|
||||
export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
|
||||
public map(dto: UpdateCustomerByIdRequestDTO, params: { companyId: UniqueID }) {
|
||||
public map(
|
||||
dto: UpdateCustomerByIdRequestDTO,
|
||||
params: { companyId: UniqueID }
|
||||
): Result<CustomerPatchProps, Error> {
|
||||
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<TaxCode>();
|
||||
/*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<TaxCode> 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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Customer, ICustomerFullSnapshot> {}
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<string, string>;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./customer-snapshot.interface";
|
||||
export * from "./customer-snapshot-builder";
|
||||
@ -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<Customer, GetCustomerByIdResponseDTO> {}
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./customer-full-snapshot-builder";
|
||||
@ -1,2 +1,2 @@
|
||||
export * from "./domain";
|
||||
export * from "./full";
|
||||
export * from "./summary";
|
||||
|
||||
@ -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<CustomerSummary, ICustomerSummarySnapshot> {}
|
||||
extends ISnapshotBuilder<CustomerSummary, CustomerSummaryDTO> {}
|
||||
|
||||
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()),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, string>;
|
||||
};
|
||||
@ -1,2 +1 @@
|
||||
export * from "./customer-summary-snapshot.interface";
|
||||
export * from "./customer-summary-snapshot-builder";
|
||||
|
||||
@ -93,12 +93,23 @@ export interface ICustomer {
|
||||
readonly currencyCode: CurrencyCode;
|
||||
}
|
||||
|
||||
type CustomerInternalProps = Omit<ICustomerCreateProps, "address" | "defaultTaxes"> & {
|
||||
readonly address: PostalAddress;
|
||||
readonly defaultTaxes: CustomerTaxes;
|
||||
};
|
||||
export type CustomerInternalProps = Omit<ICustomerCreateProps, "address" | "defaultTaxes">;
|
||||
|
||||
export class Customer extends AggregateRoot<CustomerInternalProps> 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<Customer, Error> {
|
||||
const validationResult = Customer.validateCreateProps(props);
|
||||
|
||||
@ -108,30 +119,27 @@ export class Customer extends AggregateRoot<CustomerInternalProps> 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<CustomerInternalProps> 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<void, Error> {
|
||||
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<CustomerInternalProps> implements IC
|
||||
}
|
||||
|
||||
public get address(): PostalAddress {
|
||||
return this.props.address;
|
||||
return this._address;
|
||||
}
|
||||
|
||||
public get emailPrimary(): Maybe<EmailAddress> {
|
||||
@ -247,7 +280,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
|
||||
}
|
||||
|
||||
public get defaultTaxes(): CustomerTaxes {
|
||||
return this.props.defaultTaxes;
|
||||
return this._defaultTaxes;
|
||||
}
|
||||
|
||||
public get languageCode(): LanguageCode {
|
||||
|
||||
@ -24,11 +24,6 @@ export class CustomerTaxes extends ValueObject<CustomerTaxesProps> implements IC
|
||||
return Result.ok(new CustomerTaxes(props));
|
||||
}
|
||||
|
||||
public update(partial: CustomerTaxesPatchProps): Result<CustomerTaxes, Error> {
|
||||
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().
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "./sequelize-customer.mapper";
|
||||
export * from "./sequelize-customer-domain.mapper";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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<z.infer<typeof UpdateCustomerByIdRequestSchema>>;
|
||||
export type UpdateCustomerAddressPatchRequestDTO = z.infer<
|
||||
typeof UpdateCustomerAddressPatchRequestSchema
|
||||
>;
|
||||
|
||||
export type UpdateCustomerContactPatchRequestDTO = z.infer<
|
||||
typeof UpdateCustomerContactPatchRequestSchema
|
||||
>;
|
||||
|
||||
export type UpdateCustomerByIdRequestDTO = z.infer<typeof UpdateCustomerByIdRequestSchema>;
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<typeof ListCustomersResponseSchema>;
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const CustomerStatusSchema = z.enum(["active", "inactive"]);
|
||||
|
||||
export type CustomerStatusDTO = z.infer<typeof CustomerStatusSchema>;
|
||||
@ -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<typeof CustomerSummarySchema>;
|
||||
2
modules/customers/src/common/dto/shared/index.ts
Normal file
2
modules/customers/src/common/dto/shared/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./customer-status.dto";
|
||||
export * from "./customer-summary.dto";
|
||||
@ -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 }) => <ContactCell customer={row.original} />,
|
||||
cell: ({ row }) => <ContactCell contact={row.original.contact} />,
|
||||
},
|
||||
{
|
||||
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 }) => <AddressCell address={row.original} />,
|
||||
cell: ({ row }) => <AddressCell address={row.original.address} />,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div className="flex justify-end">
|
||||
@ -177,23 +176,17 @@ export function useCustomersGridColumns(
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{primaryEmail && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={!website}
|
||||
onClick={() =>
|
||||
window.open(safeHTTPUrl(website), "_blank", "noopener,noreferrer")
|
||||
}
|
||||
>
|
||||
{t("pages.list.actions.visit_website")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!email_primary}
|
||||
onClick={() => navigator.clipboard.writeText(email_primary)}
|
||||
disabled={!primaryEmail}
|
||||
onClick={() => navigator.clipboard.writeText(primaryEmail)}
|
||||
>
|
||||
{t("pages.list.actions.copy_email")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
|
||||
@ -3,24 +3,41 @@ import { ExternalLinkIcon, MapPinIcon } from "lucide-react";
|
||||
import { useTranslation } from "../../../i18n";
|
||||
|
||||
export interface CustomerAddress {
|
||||
street: string;
|
||||
street2: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
province: string;
|
||||
country: string;
|
||||
street?: string | null;
|
||||
street2?: string | null;
|
||||
city?: string | null;
|
||||
province?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
}
|
||||
|
||||
const getGoogleMapsUrl = (adress: CustomerAddress) => {
|
||||
const fullAddress = `${adress.street}, ${adress.postalCode} ${adress.city}, ${adress.province}, ${adress.country}`;
|
||||
const clean = (value?: string | null) => value?.trim() ?? "";
|
||||
|
||||
const join = (parts: Array<string | null | undefined>, 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 (
|
||||
<address className="not-italic flex items-start gap-2 text-muted-foreground hover:text-primary transition-colors">
|
||||
<a
|
||||
@ -32,24 +49,14 @@ export const AddressCell = ({ address }: { address: CustomerAddress }) => {
|
||||
title={t("components.address_cell.open_in_google_maps")}
|
||||
>
|
||||
<MapPinIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground group-hover:text-primary" />
|
||||
|
||||
<div className="text-sm text-muted-foreground group-hover:text-foreground">
|
||||
<p>{line1}</p>
|
||||
<p>
|
||||
{line2} - {line3}
|
||||
</p>
|
||||
{line1 && <p>{line1}</p>}
|
||||
{line23 && <p>{line23}</p>}
|
||||
</div>
|
||||
|
||||
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||
</a>
|
||||
</address>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
<address className="not-italic grid gap-1 text-foreground text-sm">
|
||||
<div>{line1 || <Soft>-</Soft>}</div>
|
||||
<div>{[line2, line3].filter(Boolean).join(" • ")}</div>
|
||||
</address>
|
||||
|
||||
|
||||
*/
|
||||
|
||||
@ -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 }) => (
|
||||
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;
|
||||
}
|
||||
|
||||
const clean = (value?: string | null) => value?.trim() ?? "";
|
||||
|
||||
type ContactItem = {
|
||||
value: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
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: <MailIcon className="size-3.5" />,
|
||||
})),
|
||||
|
||||
...mobiles.map((v) => ({
|
||||
value: v,
|
||||
href: `tel:${v}`,
|
||||
icon: <SmartphoneIcon className="size-3.5" />,
|
||||
})),
|
||||
|
||||
...phones.map((v) => ({
|
||||
value: v,
|
||||
href: `tel:${v}`,
|
||||
icon: <PhoneIcon className="size-3.5" />,
|
||||
})),
|
||||
|
||||
...(website
|
||||
? [
|
||||
{
|
||||
value: website,
|
||||
href: website.startsWith("http") ? website : `https://${website}`,
|
||||
icon: <GlobeIcon className="size-3.5" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
...(fax
|
||||
? [
|
||||
{
|
||||
value: fax,
|
||||
href: `tel:${fax}`,
|
||||
icon: <PrinterIcon className="size-3.5" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 text-sm text-muted-foreground transition-colors">
|
||||
{customer.primaryEmail && (
|
||||
<a
|
||||
className="flex items-center gap-2 hover:text-foreground"
|
||||
href={`mailto:${customer.primaryEmail}`}
|
||||
>
|
||||
<MailIcon className="size-3.5" />
|
||||
{customer.primaryEmail}
|
||||
{items.map((item, idx) => (
|
||||
<a className="flex items-center gap-2 hover:text-foreground" href={item.href} key={idx}>
|
||||
{item.icon}
|
||||
{item.value}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{customer.secondaryEmail && (
|
||||
<a
|
||||
className="flex items-center gap-2 hover:text-foreground"
|
||||
href={`mailto:${customer.secondaryEmail}`}
|
||||
>
|
||||
<MailIcon className="size-3.5" />
|
||||
{customer.secondaryEmail}
|
||||
</a>
|
||||
)}
|
||||
{customer.primaryPhone && (
|
||||
<a
|
||||
className="flex items-center gap-2 hover:text-foreground"
|
||||
href={`tel:${customer.primaryPhone}`}
|
||||
>
|
||||
<PhoneIcon className="size-3.5" />
|
||||
{customer.primaryPhone}
|
||||
</a>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/*
|
||||
|
||||
<div className="grid gap-1 text-foreground text-sm my-1.5">
|
||||
{customer.email_primary && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MailIcon className="size-3.5" />
|
||||
<a className="group" href={`mailto:${customer.email_primary}`}>
|
||||
{customer.email_primary}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customer.email_secondary && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MailIcon className="size-3.5" />
|
||||
{customer.email_secondary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PhoneIcon className="size-3.5 group" />
|
||||
<span>{customer.phone_primary || customer.mobile_primary || <Soft>-</Soft>}</span>
|
||||
{customer.phone_secondary && <Soft>• {customer.phone_secondary}</Soft>}
|
||||
{customer.mobile_secondary && <Soft>• {customer.mobile_secondary}</Soft>}
|
||||
{false}
|
||||
</div>
|
||||
{false}
|
||||
</div>
|
||||
|
||||
|
||||
*/
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user