diff --git a/docs/DTOS - GUÍA DE DISEÑO.md b/docs/DTOS - GUÍA DE DISEÑO.md new file mode 100644 index 00000000..375657db --- /dev/null +++ b/docs/DTOS - GUÍA DE DISEÑO.md @@ -0,0 +1,561 @@ +## Guía de diseño de DTOs para el ERP + +--- + +* 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: + +```ts +export type ListIssuedInvoicesRequestDTO = z.infer; +``` +## 1. Esquemas Zod y DTOs + +* **El esquema valida, no “arregla” entradas**. Evitar `.default()`, `.transform()` y similares salvo excepción muy justificada. +* Las operaciones `list`, `get`, `create`, `update` y `report` no deben compartir el mismo esquema completo pero sí deben compartir `naming`, reglas de tipos, convenciones y reutilizar subesquemas comunes. +* No usar `z.string()` si se puede precisar más el tipo semántico. El schema debe expresar lo que **es** el dato, no solo cómo serializarlo. + +Ejemplos: + - booleano -> `z.boolean()` + - UUID -> `z.uuid()` + - estado cerrado -> `z.enum([...])` + - fecha ISO -> schema específico + - código ISO -> schema específico o restricción mínima consistente + +* Conservar el mismo tipo para el mismo campo en todos los esquemas. Si `customer_id` es UUID, debe ser UUID siempre. +* Conservar el mismo nombre para campos con la misma semántica a no ser que esté justificado. + +Ejeemplo. No alternar: + - `recipient_id` + - `customer_id` + - `recipient_customer_id` + +--- + +## 2. Estructura recomendada de DTOs + +## 2.1. Organización por módulo + +* Agrupar endpoints en `request/` y `response/` +* `shared/` para fragmentos reutilizables de shape +* no meter lógica de mapping en `dto/` + + +```plaintext +common/dto/ + request/ + create-proforma.request.dto.ts + get-proforma-by-id.request.dto.ts + list-proformas.request.dto.ts + update-proforma-by-id.request.dto.ts + ... + response/ + create-proforma.response.dto.ts + get-proforma-by-id.response.dto.ts + list-proformas.response.dto.ts + update-proforma-by-id.response.dto.ts + ... + shared/ + proforma-summary.dto.ts + proforma-detail.dto.ts + proforma-item.dto.ts + proforma-tax.dto.ts + 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: + +```plaintext +-[...].request.dto.ts +-[...].response.dto.ts +``` + +Ejemplos: + +* `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. + +Ejemplos: + +* booleanos -> `z.boolean()` +* enteros de paginación -> `z.number().int()` +* arrays -> `z.array(...)` +* objetos -> `z.object(...)` + + +--- + +## 5. Reglas para primitivas de negocio + +* Un VO monetario/cuantitativo siempre debe tener una sola forma de transporte en todo el ERP. + +## 5.1. Identificadores + +* Si el identificador es UUID, usar `z.uuid()`. +* Si existe un id no UUID, definir un schema específico con nombre explícito. +* No mezclar `z.string()` y `z.uuid()` para el mismo campo según endpoint. +* Un mismo identificador debe tener el mismo schema en todos los DTOs. + +Ejemplo: + +```ts +proforma_id: z.uuid() +customer_id: z.uuid() +company_id: z.uuid() +``` + +--- + +## 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: + +```ts +type MoneyDTO = { + value: string; + scale: string; + currency_code: string; +}; + +type AmountDTO = { + value: string; + scale: string; +}; + +type PercentageDTO = { + value: string; + scale: string; +} + +type QuantityDTO = { + value: string; + scale: string; +} +``` + +## 5.6. Listas + + +```ts +type ListIssuedInvoicesResponseDTO = { + page: number, + per_page: number, + total_pages: number, + total_items: number, + items: { + ... + } +} +``` + +--- + +## 6. Nullabilidad, opcionalidad y semántica de ausencia + +Significado: + + - `undefined` / campo omitido: no enviado + - `null`: enviado explícitamente sin valor + - `""`: cadena vacía, no ausencia + - `[]`: colección vacía, no ausencia + +**No cambiar estos significados salvo decisión explícita de contrato.** + +### 6.1. Para `requests` + +* Preferir `optional()` para campos no obligatorios. +* Usar `nullable()` solo cuando tenga significado de negocio “borrar/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 +``` + +### 6.3. Para `responses` + +* 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. + +--- + +## 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 + +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([]) +``` + +Correcto: + +```ts +items: z.array(...).optional() +``` + +* no usar `Partial<>`: + +Incorrecto: + +```ts +type UpdateProformaByIdRequestDTO = Partial>; +``` + + +## 8.1. Reglas para request de UPDATE en **colecciones** + +* En `update` de colecciones, mantener reemplazo completo de la colección +* Si se necesitara granularidad real en el `update` de items, valorar usar en la API una estructura explícita: + +```ts +items: { + create: [...] + update: [...] + delete: [...] +} +``` + +--- + +## 11. Reglas para responses de **LIST** y **DETAIL** + +* Separar summary y detail: + - List => summary => `ProformaSummaryDTO` + - Get <=> detail => `ProformaDetailDTO` + +* `list` debe contener solo lo imprescindible para la tabla/listado +* Puede definirse un esquema base compartido **si aporta claridad**. Ejemplo: + +```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(), +}); +``` + +Despues: + +* `summary` extiende añadiendo algún campos más +* `detail` extiende añadiendo taxes, items, notes, etc. + +--- + +## 12. Reglas para subesquemas reutilizables + +* Estructura no trivial que se repite => definir subesquema en `shared/`. +* Deben ser estructuras estables no sujetas a cambios. En caso contrario, mejor no reutilizar + +Ejemplos claros en tu caso: + +* `recipient` +* `proforma-item` +* `proforma-tax-breakdown` +* `payment-method-ref` + +--- + +## 13. Reglas específicas para errores + +* El DTO de error debe ser estable, tipado y predecible. + +## 13.1. Estructura base recomendada + +```ts +export const ErrorDetailDTOSchema = z.object({ + code: z.string(), + path: z.string().optional(), + message: z.string(), +}); + +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.2. Regla + +No dejar `errors: Record[]` si ya conoces la forma mínima útil. + +El cliente necesita poder renderizar errores sin heurísticas frágiles. + +--- + +## 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 +* el DTO intenta reflejar demasiado fielmente el dominio interno +* preferir duplicación pequeña y clara frente a abstracción prematura + +--- + +## 21. Plantilla base recomendada + +## 21.1. Request simple por id + +```ts +import { z } from "zod/v4"; + +export const GetProformaByIdRequestSchema = z.object({ + proforma_id: z.uuid(), +}); + +export type GetProformaByIdRequestDTO = z.infer; +``` + +## 21.2. Fragmento shared + +```ts +import { z } from "zod/v4"; +import { MoneyDTOSchema, PercentageDTOSchema } from "@erp/core"; + +export const ProformaTaxDTOSchema = z.object({ + taxable_amount: MoneyDTOSchema, + + iva_code: z.string(), + iva_percentage: PercentageDTOSchema, + iva_amount: MoneyDTOSchema, + + rec_code: z.string(), + rec_percentage: PercentageDTOSchema, + rec_amount: MoneyDTOSchema, + + retention_code: z.string(), + retention_percentage: PercentageDTOSchema, + retention_amount: MoneyDTOSchema, + + taxes_amount: MoneyDTOSchema, +}); + +export type ProformaTaxDTO = z.infer; +``` + +## 21.3. Update parcial sin defaults + +```ts +import { z } from "zod/v4"; + +export const UpdateProformaByIdRequestSchema = z.object({ + series: z.string().optional(), + invoice_date: z.iso.date().optional(), + operation_date: z.iso.date().optional(), + customer_id: z.uuid().optional(), + reference: z.string().optional(), + description: z.string().optional(), + notes: z.string().optional(), + language_code: z.string().optional(), + currency_code: z.string().optional(), + items: z.array(UpdateProformaItemRequestSchema).optional(), +}); + +export type UpdateProformaByIdRequestDTO = z.infer; +``` + +--- + +## 22. Checklist de revisión de un DTO + +Antes de dar un DTO por bueno, comprobar: + +1. ¿Representa contrato de transporte y nada más? +2. ¿Cada campo tiene el tipo semántico más preciso posible? +3. ¿Se distingue bien entre omitido, null, vacío y colección vacía? +4. ¿Hay defaults que estén ocultando intención? +5. ¿El naming es consistente con el resto? +6. ¿Hay subestructuras repetidas que merezcan extraerse? +7. ¿Hay abstracciones extraídas demasiado pronto? +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? + +--- +