Facturas de cliente y clientes
This commit is contained in:
parent
0e6ecaad22
commit
d7bce7fb56
@ -43,6 +43,7 @@
|
||||
"dependencies": {
|
||||
"@erp/auth": "workspace:*",
|
||||
"@erp/core": "workspace:*",
|
||||
"@erp/customers": "workspace:*",
|
||||
"@erp/customer-invoices": "workspace:*",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cls-rtracer": "^2.6.3",
|
||||
@ -69,7 +70,7 @@
|
||||
"uuid": "^11.0.5",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@ -7,6 +7,7 @@ const registeredModules: Map<string, IModuleServer> = new Map();
|
||||
const initializedModules = new Set<string>();
|
||||
|
||||
export function registerModule(pkg: IModuleServer) {
|
||||
console.log(pkg.name);
|
||||
if (registeredModules.has(pkg.name)) {
|
||||
throw new Error(`❌ Paquete "${pkg.name}" ya registrado.`);
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { authAPIModule } from "@erp/auth/api";
|
||||
import { customerInvoicesAPIModule } from "@erp/customer-invoices/api";
|
||||
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
|
||||
import customersAPIModule from "@erp/customers/api";
|
||||
|
||||
import { registerModule } from "./lib";
|
||||
|
||||
export const registerModules = () => {
|
||||
registerModule(authAPIModule);
|
||||
//registerModule(authAPIModule);
|
||||
registerModule(customersAPIModule);
|
||||
registerModule(customerInvoicesAPIModule);
|
||||
};
|
||||
|
||||
187
docs/CONVENCION NOMBRADO DE ESQUEMAS DTO.md
Normal file
187
docs/CONVENCION NOMBRADO DE ESQUEMAS DTO.md
Normal file
@ -0,0 +1,187 @@
|
||||
# 📝 Guía de Convenciones para Nombres de Schemas
|
||||
|
||||
Esta guía define cómo nombrar los Schemas de **Requests** y **Responses** según el tipo de operación y el contexto (recurso único o colección).
|
||||
|
||||
---
|
||||
|
||||
## 1️⃣ Operaciones sobre recurso único
|
||||
|
||||
**Patrón**
|
||||
<Verbo><Entidad><Condición><TipoDeSchema>
|
||||
|
||||
|
||||
- **Verbo**: describe la acción (`Get`, `Delete`, `Update`, `Patch`, `Create`).
|
||||
- **Entidad**: en singular (`Product`, `Customer`, `Invoice`).
|
||||
- **Condición**: criterio de selección (`ById`, `BySlug`, `ByEmail`).
|
||||
- **TipoDeSchema**: origen o propósito (`ParamsSchema`, `CommandSchema`, `QuerySchema`).
|
||||
|
||||
**Ejemplos ✅**
|
||||
- `GetProductByIdParamsSchema`
|
||||
- `DeleteCustomerByIdParamsSchema`
|
||||
- `UpdateInvoiceByIdCommandSchema`
|
||||
|
||||
**Ejemplos ❌**
|
||||
- `ProductGetByIdParamsSchema` (orden invertido, menos natural).
|
||||
- `GetProductsByIdParamsSchema` (plural innecesario en recurso único).
|
||||
|
||||
**Lectura natural**: *"Esquema de parámetros para obtener un producto por id"*.
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ Operaciones sobre colecciones
|
||||
|
||||
**Patrón**
|
||||
<Entidad>List<Criterio/Filtro><TipoDeSchema>
|
||||
|
||||
- **Entidad**: en singular (`Product`, `Customer`).
|
||||
- **List**: indica conjunto/listado.
|
||||
- **Criterio/Filtro**: tipo de filtrado (`Criteria`, `Query`).
|
||||
- **TipoDeSchema**: `Schema` o `ResponseSchema` según corresponda.
|
||||
|
||||
**Ejemplos ✅**
|
||||
- `ProductListCriteriaSchema`
|
||||
- `CustomerListResponseSchema`
|
||||
- `InvoiceListQuerySchema`
|
||||
|
||||
**Ejemplos ❌**
|
||||
- `ListProductCriteriaSchema` (el orden “ListProduct” suena a verbo y no a colección).
|
||||
- `ProductsListCriteriaSchema` (plural innecesario en el nombre base).
|
||||
|
||||
**Lectura natural**: *"Esquema de criterios para una lista de productos"*.
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ Reglas generales
|
||||
|
||||
1. **Singular siempre** para el nombre base de la entidad.
|
||||
2. El **verbo solo** en operaciones sobre un recurso único o mutaciones.
|
||||
3. En listados, el **sustantivo principal primero** (`ProductList`, `CustomerList`), nunca `ListProduct`.
|
||||
4. Sufijos estándar:
|
||||
- `CommandSchema` → body de mutaciones (create/update/patch).
|
||||
- `ParamsSchema` → route params (`/:id`).
|
||||
- `QuerySchema` → query string no normalizada.
|
||||
- `CriteriaSchema` → query normalizada con patrón Criteria.
|
||||
- `ResponseSchema` → respuesta de la API.
|
||||
5. Mantener **consistencia**: si usas `ProductListCriteriaSchema`, no alternes con `ProductsCriteriaSchema`.
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ Ejemplos positivos y negativos
|
||||
|
||||
| ✅ Correcto | ❌ Incorrecto | Motivo del error |
|
||||
|------------------------------------------|---------------------------------------------|----------------------------------------------|
|
||||
| `GetCustomerByIdParamsSchema` | `CustomerGetByIdParamsSchema` | Orden invertido. |
|
||||
| `ProductListCriteriaSchema` | `ListProductCriteriaSchema` | Orden “ListProduct” no natural en inglés. |
|
||||
| `CustomerListResponseSchema` | `CustomersListResponseSchema` | Plural innecesario. |
|
||||
| `DeleteInvoiceByIdParamsSchema` | `DeleteInvoicesByIdParamsSchema` | Plural innecesario en recurso único. |
|
||||
| `UpdateProductByIdCommandSchema` | `UpdateProductCommandByIdSchema` | `ById` debe ir antes del tipo de esquema. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
# Convenciones de nombres para Schemas (Requests y Responses)
|
||||
|
||||
## Tabla de rutas ↔ Schemas
|
||||
|
||||
| **Método** | **Ruta** | **Caso de uso** | **Request Schema (validación)** | **Response Schema (DTO)** | **Notas** |
|
||||
|-----------:|---------------------|--------------------------------------------------|--------------------------------------------|-------------------------------------------|-----------|
|
||||
| GET | `/entities` | Listado con criterios (Criteria normalizado) | `EntityListCriteriaSchema` | `EntityListResponseSchema` | `data: EntitySummary[]`, `pageNumber`, `pageSize`, `totalItems`. |
|
||||
| GET | `/entities` | Búsqueda/filtrado por query params “raw” | `SearchEntityQuerySchema` | `EntityListResponseSchema` | Úsalo si **no** aplicas patrón Criteria. |
|
||||
| GET | `/entities/:id` | Obtener por ID | `GetEntityByIdParamsSchema` | `EntityDetailResponseSchema` | Si devuelves el recurso “puro”, puedes usar `EntityResourceSchema`. |
|
||||
| POST | `/entities` | Crear recurso | `CreateEntityCommandSchema` | `EntityCreatedResponseSchema` **o** `EntityResourceSchema` | Si devuelves 201 + recurso completo, usa `EntityResourceSchema`. |
|
||||
| PUT | `/entities/:id` | Reemplazar recurso (idempotente) | `UpdateEntityCommandSchema` + `UpdateEntityByIdParamsSchema` | `EntityResourceSchema` | También válido con PATCH para cambios parciales. |
|
||||
| PATCH | `/entities/:id` | Actualizar parcialmente | `PatchEntityCommandSchema` + `UpdateEntityByIdParamsSchema` | `EntityResourceSchema` | Usa un comando separado si el payload difiere del PUT. |
|
||||
| DELETE | `/entities/:id` | Eliminar por ID | `DeleteEntityByIdParamsSchema` | — (204 No Content) **o** `EntityDeletedResponseSchema` | 204 recomendado. Si devuelves body, usa `EntityDeletedResponseSchema`. |
|
||||
|
||||
---
|
||||
|
||||
## Esquemas reutilizables (Responses)
|
||||
|
||||
- **Item para listados:**
|
||||
`EntitySummarySchema`
|
||||
|
||||
- **Recurso completo:**
|
||||
`EntityResourceSchema`
|
||||
|
||||
- **Respuestas compuestas:**
|
||||
- `EntityListResponseSchema` → `data: EntitySummarySchema[]` + paginación/meta
|
||||
- `EntityDetailResponseSchema` → típicamente `EntityResourceSchema`
|
||||
- `EntityCreatedResponseSchema` → `{ id, createdAt, ... }` si no devuelves el recurso completo
|
||||
- `EntityDeletedResponseSchema` → `{ id, deleted: true }` si no usas 204
|
||||
|
||||
---
|
||||
|
||||
## Convenciones de sufijos
|
||||
|
||||
### Requests
|
||||
- `...CommandSchema` → **body** (mutaciones)
|
||||
- `...ParamsSchema` → **route params** (`/:id`)
|
||||
- `...QuerySchema` → **query string** (`?page=...`)
|
||||
- `...CriteriaSchema` → para queries normalizadas con patrón Criteria
|
||||
|
||||
### Responses
|
||||
- `...ListResponseSchema` → listado paginado
|
||||
- `...SummarySchema` → item de listado
|
||||
- `...DetailResponseSchema` → detalle de recurso
|
||||
- `...ResourceSchema` → representación completa del recurso
|
||||
- `...CreatedResponseSchema` → recurso recién creado
|
||||
- `...DeletedResponseSchema` → confirmación de borrado (si no usas 204)
|
||||
|
||||
# Ejemplos de nombres de Schemas por entidad
|
||||
|
||||
## 1. Customers
|
||||
|
||||
### Requests
|
||||
- `CreateCustomerCommandSchema`
|
||||
- `UpdateCustomerCommandSchema`
|
||||
- `PatchCustomerCommandSchema`
|
||||
- `DeleteCustomerByIdParamsSchema`
|
||||
- `GetCustomerByIdParamsSchema`
|
||||
- `CustomerListCriteriaSchema`
|
||||
- `SearchCustomerQuerySchema`
|
||||
|
||||
### Responses
|
||||
- `CustomerSummarySchema`
|
||||
- `CustomerResourceSchema`
|
||||
- `CustomerListResponseSchema`
|
||||
- `CustomerDetailResponseSchema`
|
||||
- `CustomerCreatedResponseSchema`
|
||||
- `CustomerDeletedResponseSchema`
|
||||
|
||||
---
|
||||
|
||||
## 2. Products
|
||||
|
||||
### Requests
|
||||
- `CreateProductCommandSchema`
|
||||
- `UpdateProductCommandSchema`
|
||||
- `PatchProductCommandSchema`
|
||||
- `DeleteProductByIdParamsSchema`
|
||||
- `GetProductByIdParamsSchema`
|
||||
- `ProductListCriteriaSchema`
|
||||
- `SearchProductQuerySchema`
|
||||
|
||||
### Responses
|
||||
- `ProductSummarySchema`
|
||||
- `ProductResourceSchema`
|
||||
- `ProductListResponseSchema`
|
||||
- `ProductDetailResponseSchema`
|
||||
- `ProductCreatedResponseSchema`
|
||||
- `ProductDeletedResponseSchema`
|
||||
|
||||
---
|
||||
|
||||
## Notas
|
||||
- Reutilizar esquemas básicos (`EntitySummarySchema`, `EntityResourceSchema`) en respuestas compuestas para cumplir el principio **DRY**.
|
||||
- **Sufijos**: mantener siempre los sufijos `CommandSchema`, `ParamsSchema`, `QuerySchema`, `CriteriaSchema`, `ListResponseSchema`, `SummarySchema`, `DetailResponseSchema`, `ResourceSchema`, `CreatedResponseSchema`, `DeletedResponseSchema`.
|
||||
- **Pluralización**: usar singular para el nombre de la entidad en los Schemas (ej. `Customer`, `Product`), incluso si la ruta es plural (`/customers`, `/products`).
|
||||
- **Carpetas sugeridas**:
|
||||
modules/
|
||||
<entity>/
|
||||
application/
|
||||
dto/
|
||||
requests/
|
||||
responses/
|
||||
domain/
|
||||
infrastructure/
|
||||
api/
|
||||
@ -11,7 +11,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/axios": "^0.14.4",
|
||||
"@types/dinero.js": "^1.9.4",
|
||||
"@types/express": "^4.17.21",
|
||||
|
||||
27
modules/core/src/common/dto/critera.dto.ts
Normal file
27
modules/core/src/common/dto/critera.dto.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
/**
|
||||
Esquema del objeto normalizado esperado por Criteria.fromPrimitives(...)
|
||||
No aplica defaults ni correciones: solo valida.
|
||||
*/
|
||||
export const FilterPrimitiveSchema = z.object({
|
||||
// Campos mínimos ya normalizados por el conversor
|
||||
field: z.string(),
|
||||
operator: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const CriteriaSchema = z.object({
|
||||
filters: z.array(FilterPrimitiveSchema),
|
||||
|
||||
// Preferimos omitido; si viene, no puede ser cadena vacía
|
||||
orderBy: z.string().min(1).optional(),
|
||||
|
||||
order: z.enum(["asc", "desc"]),
|
||||
|
||||
// Ya son números (normalizados); validaciones lógicas
|
||||
pageSize: z.number().int().min(1, { message: "pageSize must be a positive integer" }),
|
||||
pageNumber: z.number().int().min(0, { message: "pageNumber must be a non-negative integer" }),
|
||||
});
|
||||
|
||||
export type CriteriaDTO = z.infer<typeof CriteriaSchema>;
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./critera.dto";
|
||||
export * from "./error.dto";
|
||||
export * from "./list.view.dto";
|
||||
export * from "./metadata.dto";
|
||||
|
||||
@ -2,12 +2,12 @@ import * as z from "zod/v4";
|
||||
import { MetadataSchema } from "./metadata.dto";
|
||||
|
||||
/**
|
||||
* Crea un esquema Zod que representa un ListViewDTO genérico.
|
||||
* Crea un esquema Zod que representa un resultado ListViewDTO genérico.
|
||||
*
|
||||
* @param itemSchema Esquema Zod del elemento T
|
||||
* @returns Zod schema para ListViewDTO<T>
|
||||
*/
|
||||
export const createListViewSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
|
||||
export const createListViewResultSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
|
||||
z.object({
|
||||
page: z.number().int().min(1, "Page must be a positive integer"),
|
||||
per_page: z.number().int().min(1, "Items per page must be a positive integer"),
|
||||
|
||||
@ -10,7 +10,11 @@
|
||||
"./globals.css": "./src/web/globals.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"dinero.js": "^1.9.1"
|
||||
"@erp/core": "workspace:*",
|
||||
"dinero.js": "^1.9.1",
|
||||
"express": "^4.18.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@ -27,7 +31,6 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@erp/core": "workspace:*",
|
||||
"@erp/customers": "workspace:*",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@repo/rdx-criteria": "workspace:*",
|
||||
@ -40,7 +43,6 @@
|
||||
"ag-grid-community": "^33.3.0",
|
||||
"ag-grid-react": "^33.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"express": "^4.18.2",
|
||||
"i18next": "^25.1.1",
|
||||
"lucide-react": "^0.503.0",
|
||||
"react": "^19.1.0",
|
||||
@ -48,11 +50,9 @@
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"zod": "^3.25.67"
|
||||
"tw-animate-css": "^1.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import {
|
||||
CreateCustomerInvoiceCommandSchema,
|
||||
DeleteCustomerInvoiceByIdQuerySchema,
|
||||
DeleteCustomerInvoiceByIdQuerySchema as GetCustomerInvoiceByIdQuerySchema,
|
||||
ListCustomerInvoicesQuerySchema,
|
||||
} from "../../../common/dto";
|
||||
import {
|
||||
buildCreateCustomerInvoicesController,
|
||||
@ -28,7 +27,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
|
||||
"/",
|
||||
//checkTabContext,
|
||||
//checkUser,
|
||||
validateRequest(ListCustomerInvoicesQuerySchema, "params"),
|
||||
validateRequest(CustomerInvoiceListCriteriaSchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
buildListCustomerInvoicesController(database).execute(req, res, next);
|
||||
}
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure";
|
||||
import { Name, TINNumber, UniqueID } from "@shared/contexts";
|
||||
|
||||
import { Contact, IContactProps } from "../../domain";
|
||||
import { IInvoicingContext } from "../InvoicingContext";
|
||||
import { Contact_Model, TCreationContact_Model } from "../sequelize/contact.mo.del";
|
||||
import { IContactAddressMapper, createContactAddressMapper } from "./contactAddress.mapper";
|
||||
|
||||
export interface IContactMapper
|
||||
extends ISequelizeMapper<Contact_Model, TCreationContact_Model, Contact> {}
|
||||
|
||||
class ContactMapper
|
||||
extends SequelizeMapper<Contact_Model, TCreationContact_Model, Contact>
|
||||
implements IContactMapper
|
||||
{
|
||||
public constructor(props: { addressMapper: IContactAddressMapper; context: IInvoicingContext }) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
protected toDomainMappingImpl(source: Contact_Model, params: any): Contact {
|
||||
if (!source.billingAddress) {
|
||||
this.handleRequiredFieldError(
|
||||
"billingAddress",
|
||||
new Error("Missing participant's billing address")
|
||||
);
|
||||
}
|
||||
|
||||
if (!source.shippingAddress) {
|
||||
this.handleRequiredFieldError(
|
||||
"shippingAddress",
|
||||
new Error("Missing participant's shipping address")
|
||||
);
|
||||
}
|
||||
|
||||
const billingAddress = this.props.addressMapper.mapToDomain(source.billingAddress!, params);
|
||||
|
||||
const shippingAddress = this.props.addressMapper.mapToDomain(source.shippingAddress!, params);
|
||||
|
||||
const props: IContactProps = {
|
||||
tin: this.mapsValue(source, "tin", TINNumber.create),
|
||||
firstName: this.mapsValue(source, "first_name", Name.create),
|
||||
lastName: this.mapsValue(source, "last_name", Name.create),
|
||||
companyName: this.mapsValue(source, "company_name", Name.create),
|
||||
billingAddress,
|
||||
shippingAddress,
|
||||
};
|
||||
|
||||
const id = this.mapsValue(source, "id", UniqueID.create);
|
||||
const contactOrError = Contact.create(props, id);
|
||||
|
||||
if (contactOrError.isFailure) {
|
||||
throw contactOrError.error;
|
||||
}
|
||||
|
||||
return contactOrError.object;
|
||||
}
|
||||
}
|
||||
|
||||
export const createContactMapper = (context: IInvoicingContext): IContactMapper =>
|
||||
new ContactMapper({
|
||||
addressMapper: createContactAddressMapper(context),
|
||||
context,
|
||||
});
|
||||
@ -1,65 +0,0 @@
|
||||
import {
|
||||
ISequelizeMapper,
|
||||
SequelizeMapper,
|
||||
} from "@/contexts/common/infrastructure";
|
||||
import {
|
||||
City,
|
||||
Country,
|
||||
Email,
|
||||
Note,
|
||||
Phone,
|
||||
PostalCode,
|
||||
Province,
|
||||
Street,
|
||||
UniqueID,
|
||||
} from "@shared/contexts";
|
||||
import { ContactAddress, IContactAddressProps } from "../../domain";
|
||||
import { IInvoicingContext } from "../InvoicingContext";
|
||||
import {
|
||||
ContactAddress_Model,
|
||||
TCreationContactAddress_Attributes,
|
||||
} from "../sequelize";
|
||||
|
||||
export interface IContactAddressMapper
|
||||
extends ISequelizeMapper<
|
||||
ContactAddress_Model,
|
||||
TCreationContactAddress_Attributes,
|
||||
ContactAddress
|
||||
> {}
|
||||
|
||||
export const createContactAddressMapper = (
|
||||
context: IInvoicingContext
|
||||
): IContactAddressMapper => new ContactAddressMapper({ context });
|
||||
|
||||
class ContactAddressMapper
|
||||
extends SequelizeMapper<
|
||||
ContactAddress_Model,
|
||||
TCreationContactAddress_Attributes,
|
||||
ContactAddress
|
||||
>
|
||||
implements IContactAddressMapper
|
||||
{
|
||||
protected toDomainMappingImpl(source: ContactAddress_Model, params: any) {
|
||||
const id = this.mapsValue(source, "id", UniqueID.create);
|
||||
|
||||
const props: IContactAddressProps = {
|
||||
type: source.type,
|
||||
street: this.mapsValue(source, "street", Street.create),
|
||||
city: this.mapsValue(source, "city", City.create),
|
||||
province: this.mapsValue(source, "province", Province.create),
|
||||
postalCode: this.mapsValue(source, "postal_code", PostalCode.create),
|
||||
country: this.mapsValue(source, "country", Country.create),
|
||||
email: this.mapsValue(source, "email", Email.create),
|
||||
phone: this.mapsValue(source, "phone", Phone.create),
|
||||
notes: this.mapsValue(source, "notes", Note.create),
|
||||
};
|
||||
|
||||
const addressOrError = ContactAddress.create(props, id);
|
||||
|
||||
if (addressOrError.isFailure) {
|
||||
throw addressOrError.error;
|
||||
}
|
||||
|
||||
return addressOrError.object;
|
||||
}
|
||||
}
|
||||
@ -1,33 +1,8 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
/**
|
||||
* DTO que transporta los parámetros de la consulta (paginación, filtros, etc.)
|
||||
* para la búsqueda de facturas de cliente.
|
||||
*
|
||||
* Este DTO es utilizado por el endpoint:
|
||||
* `GET /customer-invoices` (listado / búsqueda de facturas).
|
||||
*
|
||||
*/
|
||||
import { criteriaSchema } from "@erp/core/criteria"; // El esquema genérico validado antes
|
||||
|
||||
export const ListCustomerInvoicesQuerySchema = z.object({
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(25),
|
||||
fromDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || !Number.isNaN(Date.parse(val)), {
|
||||
message: "Invalid date format for fromDate",
|
||||
}),
|
||||
toDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || !Number.isNaN(Date.parse(val)), {
|
||||
message: "Invalid date format for toDate",
|
||||
}),
|
||||
status: z.enum(["DRAFT", "POSTED", "PAID", "CANCELLED"]).default("DRAFT"),
|
||||
customerId: z.string().optional(),
|
||||
sortBy: z.enum(["issueDate", "totalAmount", "number"]).default("issueDate"),
|
||||
sortDir: z.enum(["ASC", "DESC"]).default("DESC"),
|
||||
});
|
||||
export const CustomerInvoiceListCriteriaSchema = criteriaSchema;
|
||||
export type CustomerInvoiceListCriteria = z.infer<typeof CustomerInvoiceListCriteriaSchema>;
|
||||
|
||||
export type ListCustomerInvoicesQueryDTO = z.infer<typeof ListCustomerInvoicesQuerySchema>;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { MetadataSchema, createListViewSchema } from "@erp/core";
|
||||
import { MetadataSchema, createListViewResultSchema } from "@erp/core";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const ListCustomerInvoicesResultSchema = createListViewSchema(
|
||||
export const ListCustomerInvoicesResultSchema = createListViewResultSchema(
|
||||
z.object({
|
||||
id: z.uuid(),
|
||||
invoice_status: z.string(),
|
||||
|
||||
@ -10,8 +10,17 @@
|
||||
"./globals.css": "./src/web/globals.css",
|
||||
"./components": "./src/web/components/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@erp/core": "workspace:*",
|
||||
"dinero.js": "^1.9.1",
|
||||
"express": "^4.18.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@hookform/devtools": "^4.4.0",
|
||||
"@types/dinero.js": "^1.9.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.3",
|
||||
@ -20,7 +29,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-grid-community/locale": "34.0.0",
|
||||
"@erp/core": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@erp/customers": "workspace:*",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@repo/rdx-criteria": "workspace:*",
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
@ -28,24 +40,20 @@
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"@repo/shadcn-ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.74.11",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"ag-grid-community": "^33.3.0",
|
||||
"ag-grid-react": "^33.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"express": "^4.18.2",
|
||||
"i18next": "^25.1.1",
|
||||
"lucide-react": "^0.503.0",
|
||||
"react": "^19.1.0",
|
||||
"react-data-table-component": "^7.7.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"use-debounce": "^10.0.5",
|
||||
"use-query": "^1.0.2",
|
||||
"zod": "^3.25.67"
|
||||
"tw-animate-css": "^1.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
import { DuplicateEntityError, ITransactionManager } from "@erp/core/api";
|
||||
import { CreateCustomerCommandDTO } from "@erp/customers/common/dto";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { Transaction } from "sequelize";
|
||||
import { ICustomerService } from "../../domain";
|
||||
import { mapDTOToCustomerProps } from "../helpers";
|
||||
import { CreateCustomersPresenter } from "./presenter";
|
||||
|
||||
export class CreateCustomerUseCase {
|
||||
constructor(
|
||||
private readonly service: ICustomerService,
|
||||
private readonly transactionManager: ITransactionManager,
|
||||
private readonly presenter: CreateCustomersPresenter
|
||||
) {}
|
||||
|
||||
public execute(dto: CreateCustomerCommandDTO) {
|
||||
const invoicePropsOrError = mapDTOToCustomerProps(dto);
|
||||
|
||||
if (invoicePropsOrError.isFailure) {
|
||||
return Result.fail(invoicePropsOrError.error);
|
||||
}
|
||||
|
||||
const { props, id } = invoicePropsOrError.data;
|
||||
|
||||
const invoiceOrError = this.service.build(props, id);
|
||||
|
||||
if (invoiceOrError.isFailure) {
|
||||
return Result.fail(invoiceOrError.error);
|
||||
}
|
||||
|
||||
const newInvoice = invoiceOrError.data;
|
||||
|
||||
return this.transactionManager.complete(async (transaction: Transaction) => {
|
||||
try {
|
||||
const duplicateCheck = await this.service.existsById(id, transaction);
|
||||
|
||||
if (duplicateCheck.isFailure) {
|
||||
return Result.fail(duplicateCheck.error);
|
||||
}
|
||||
|
||||
if (duplicateCheck.data) {
|
||||
return Result.fail(new DuplicateEntityError("Customer", id.toString()));
|
||||
}
|
||||
|
||||
const result = await this.service.save(newInvoice, transaction);
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
const viewDTO = this.presenter.toDTO(newInvoice);
|
||||
return Result.ok(viewDTO);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./create-customer.use-case";
|
||||
export * from "./presenter";
|
||||
@ -0,0 +1,27 @@
|
||||
import { Customer } from "@erp/customers/api/domain";
|
||||
import { CustomersCreationResultDTO } from "@erp/customers/common/dto";
|
||||
|
||||
export class CreateCustomersPresenter {
|
||||
public toDTO(invoice: Customer): CustomersCreationResultDTO {
|
||||
return {
|
||||
id: invoice.id.toPrimitive(),
|
||||
|
||||
invoice_status: invoice.status.toString(),
|
||||
invoice_number: invoice.invoiceNumber.toString(),
|
||||
invoice_series: invoice.invoiceSeries.toString(),
|
||||
issue_date: invoice.issueDate.toISOString(),
|
||||
operation_date: invoice.operationDate.toISOString(),
|
||||
language_code: "ES",
|
||||
currency: "EUR",
|
||||
|
||||
//subtotal_price: invoice.calculateSubtotal().toPrimitive(),
|
||||
//total_price: invoice.calculateTotal().toPrimitive(),
|
||||
|
||||
//recipient: CustomerParticipantPresenter(customer.recipient),
|
||||
|
||||
metadata: {
|
||||
entity: "customer",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./create-customers.presenter";
|
||||
@ -0,0 +1,40 @@
|
||||
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
|
||||
import { DeleteCustomerByIdQueryDTO } from "@erp/customers/common/dto";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { ICustomerService } from "../../domain";
|
||||
|
||||
export class DeleteCustomerUseCase {
|
||||
constructor(
|
||||
private readonly service: ICustomerService,
|
||||
private readonly transactionManager: ITransactionManager
|
||||
) {}
|
||||
|
||||
public execute(dto: DeleteCustomerByIdQueryDTO) {
|
||||
const idOrError = UniqueID.create(dto.id);
|
||||
|
||||
if (idOrError.isFailure) {
|
||||
return Result.fail(idOrError.error);
|
||||
}
|
||||
|
||||
const id = idOrError.data;
|
||||
|
||||
return this.transactionManager.complete(async (transaction) => {
|
||||
try {
|
||||
const existsCheck = await this.service.existsById(id, transaction);
|
||||
|
||||
if (existsCheck.isFailure) {
|
||||
return Result.fail(existsCheck.error);
|
||||
}
|
||||
|
||||
if (!existsCheck.data) {
|
||||
return Result.fail(new EntityNotFoundError("Customer", id.toString()));
|
||||
}
|
||||
|
||||
return await this.service.deleteById(id, transaction);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./delete-customer.use-case";
|
||||
@ -0,0 +1,36 @@
|
||||
import { ITransactionManager } from "@erp/core/api";
|
||||
import { GetCustomerByIdQueryDTO } from "@erp/customers/common/dto";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { ICustomerService } from "../../domain";
|
||||
import { GetCustomerPresenter } from "./presenter";
|
||||
|
||||
export class GetCustomerUseCase {
|
||||
constructor(
|
||||
private readonly service: ICustomerService,
|
||||
private readonly transactionManager: ITransactionManager,
|
||||
private readonly presenter: GetCustomerPresenter
|
||||
) {}
|
||||
|
||||
public execute(dto: GetCustomerByIdQueryDTO) {
|
||||
const idOrError = UniqueID.create(dto.id);
|
||||
|
||||
if (idOrError.isFailure) {
|
||||
return Result.fail(idOrError.error);
|
||||
}
|
||||
|
||||
return this.transactionManager.complete(async (transaction) => {
|
||||
try {
|
||||
const invoiceOrError = await this.service.getById(idOrError.data, transaction);
|
||||
if (invoiceOrError.isFailure) {
|
||||
return Result.fail(invoiceOrError.error);
|
||||
}
|
||||
|
||||
const getDTO = this.presenter.toDTO(invoiceOrError.data);
|
||||
return Result.ok(getDTO);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./get-customer.use-case";
|
||||
export * from "./presenter";
|
||||
@ -0,0 +1,16 @@
|
||||
import { CustomerItem } from "#/server/domain";
|
||||
import { IInvoicingContext } from "#/server/intrastructure";
|
||||
import { Collection } from "@rdx/core";
|
||||
|
||||
export const customerItemPresenter = (items: Collection<CustomerItem>, context: IInvoicingContext) =>
|
||||
items.totalCount > 0
|
||||
? items.items.map((item: CustomerItem) => ({
|
||||
description: item.description.toString(),
|
||||
quantity: item.quantity.toString(),
|
||||
unit_measure: "",
|
||||
unit_price: item.unitPrice.toPrimitive() as IMoney_Response_DTO,
|
||||
subtotal: item.calculateSubtotal().toPrimitive() as IMoney_Response_DTO,
|
||||
tax_amount: item.calculateTaxAmount().toPrimitive() as IMoney_Response_DTO,
|
||||
total: item.calculateTotal().toPrimitive() as IMoney_Response_DTO,
|
||||
}))
|
||||
: [];
|
||||
@ -0,0 +1,26 @@
|
||||
import { ICustomerParticipant } from "@/contexts/invoicing/domain";
|
||||
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
|
||||
import { ICreateCustomer_Participant_Response_DTO } from "@shared/contexts";
|
||||
import { CustomerParticipantAddressPresenter } from "./CustomerParticipantAddress.presenter";
|
||||
|
||||
export const CustomerParticipantPresenter = async (
|
||||
participant: ICustomerParticipant,
|
||||
context: IInvoicingContext,
|
||||
): Promise<ICreateCustomer_Participant_Response_DTO | undefined> => {
|
||||
return {
|
||||
id: participant.id.toString(),
|
||||
tin: participant.tin.toString(),
|
||||
first_name: participant.firstName.toString(),
|
||||
last_name: participant.lastName.toString(),
|
||||
company_name: participant.companyName.toString(),
|
||||
|
||||
billing_address: await CustomerParticipantAddressPresenter(
|
||||
participant.billingAddress!,
|
||||
context,
|
||||
),
|
||||
shipping_address: await CustomerParticipantAddressPresenter(
|
||||
participant.shippingAddress!,
|
||||
context,
|
||||
),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import { CustomerParticipantAddress } from "@/contexts/invoicing/domain";
|
||||
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
|
||||
import { ICreateCustomer_AddressParticipant_Response_DTO } from "@shared/contexts";
|
||||
|
||||
export const CustomerParticipantAddressPresenter = async (
|
||||
address: CustomerParticipantAddress,
|
||||
context: IInvoicingContext,
|
||||
): Promise<ICreateCustomer_AddressParticipant_Response_DTO> => {
|
||||
return {
|
||||
id: address.id.toString(),
|
||||
street: address.street.toString(),
|
||||
city: address.city.toString(),
|
||||
postal_code: address.postalCode.toString(),
|
||||
province: address.province.toString(),
|
||||
country: address.country.toString(),
|
||||
email: address.email.toString(),
|
||||
phone: address.phone.toString(),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,65 @@
|
||||
import { GetCustomerByIdResultDTO } from "../../../../common/dto";
|
||||
import { Customer } from "../../../domain";
|
||||
|
||||
export interface GetCustomerPresenter {
|
||||
toDTO: (customer: Customer) => GetCustomerByIdResultDTO;
|
||||
}
|
||||
|
||||
export const getCustomerPresenter: GetCustomerPresenter = {
|
||||
toDTO: (customer: Customer): GetCustomerByIdResultDTO => ({
|
||||
id: customer.id.toPrimitive(),
|
||||
|
||||
invoice_status: customer.status.toString(),
|
||||
invoice_number: customer.invoiceNumber.toString(),
|
||||
invoice_series: customer.invoiceSeries.toString(),
|
||||
issue_date: customer.issueDate.toDateString(),
|
||||
operation_date: customer.operationDate.toDateString(),
|
||||
language_code: "ES",
|
||||
currency: customer.currency,
|
||||
|
||||
metadata: {
|
||||
entity: "customers",
|
||||
},
|
||||
|
||||
//subtotal: customer.calculateSubtotal().toPrimitive(),
|
||||
|
||||
//total: customer.calculateTotal().toPrimitive(),
|
||||
|
||||
/*items:
|
||||
customer.items.size() > 0
|
||||
? customer.items.map((item: CustomerItem) => ({
|
||||
description: item.description.toString(),
|
||||
quantity: item.quantity.toPrimitive(),
|
||||
unit_measure: "",
|
||||
unit_price: item.unitPrice.toPrimitive(),
|
||||
subtotal: item.calculateSubtotal().toPrimitive(),
|
||||
//tax_amount: item.calculateTaxAmount().toPrimitive(),
|
||||
total: item.calculateTotal().toPrimitive(),
|
||||
}))
|
||||
: [],*/
|
||||
|
||||
//sender: {}, //await CustomerParticipantPresenter(customer.senderId, context),
|
||||
|
||||
/*recipient: await CustomerParticipantPresenter(customer.recipient, context),
|
||||
items: customerItemPresenter(customer.items, context),
|
||||
|
||||
payment_term: {
|
||||
payment_type: "",
|
||||
due_date: "",
|
||||
},
|
||||
|
||||
due_amount: {
|
||||
currency: customer.currency.toString(),
|
||||
precision: 2,
|
||||
amount: 0,
|
||||
},
|
||||
|
||||
custom_fields: [],
|
||||
|
||||
metadata: {
|
||||
create_time: "",
|
||||
last_updated_time: "",
|
||||
delete_time: "",
|
||||
},*/
|
||||
}),
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./get-invoice.presenter";
|
||||
@ -0,0 +1,45 @@
|
||||
import { DomainValidationError, ValidationErrorDetail } from "@erp/core/api";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
/**
|
||||
* Extrae un valor de un Result si es válido.
|
||||
* Si es un fallo, agrega un ValidationErrorDetail al array proporcionado.
|
||||
* @param result - El resultado a evaluar.
|
||||
* @param path - La ruta del error para el detalle de validación.
|
||||
* @param errors - El array donde se agregarán los errores de validación.
|
||||
* @returns El valor extraído si el resultado es exitoso, o undefined si es un fallo.
|
||||
* @template T - El tipo de dato esperado en el resultado exitoso.
|
||||
* @throws {Error} Si el resultado es un fallo y no es una instancia de DomainValidationError.
|
||||
* @example
|
||||
* const result = Result.ok(42);
|
||||
* const value = extractOrPushError(result, 'some.path', []);
|
||||
* console.log(value); // 42
|
||||
* const errorResult = Result.fail(new Error('Something went wrong'));
|
||||
* const value = extractOrPushError(errorResult, 'some.path', []);
|
||||
* console.log(value); // undefined
|
||||
* // errors will contain [{ path: 'some.path', message: 'Something went wrong' }]
|
||||
*
|
||||
* @see Result
|
||||
* @see DomainValidationError
|
||||
* @see ValidationErrorDetail
|
||||
|
||||
*/
|
||||
export function extractOrPushError<T>(
|
||||
result: Result<T, Error>,
|
||||
path: string,
|
||||
errors: ValidationErrorDetail[]
|
||||
): T | undefined {
|
||||
if (result.isFailure) {
|
||||
const error = result.error;
|
||||
|
||||
if (error instanceof DomainValidationError) {
|
||||
errors.push({ path, message: error.detail });
|
||||
} else {
|
||||
errors.push({ path, message: error.message });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
/**
|
||||
*
|
||||
* @param obj - El objeto a evaluar.
|
||||
* @template T - El tipo del objeto.
|
||||
* @description Verifica si un objeto no tiene campos con valor undefined.
|
||||
*
|
||||
* Esta función recorre los valores del objeto y devuelve true si todos los valores son diferentes de undefined.
|
||||
* Si al menos un valor es undefined, devuelve false.
|
||||
*
|
||||
* @example
|
||||
* const obj = { a: 1, b: 'test', c: null };
|
||||
* console.log(hasNoUndefinedFields(obj)); // true
|
||||
*
|
||||
* const objWithUndefined = { a: 1, b: undefined, c: null };
|
||||
* console.log(hasNoUndefinedFields(objWithUndefined)); // false
|
||||
*
|
||||
* @template T - El tipo del objeto.
|
||||
* @param obj - El objeto a evaluar.
|
||||
* @returns true si el objeto no tiene campos undefined, false en caso contrario.
|
||||
*/
|
||||
|
||||
export function hasNoUndefinedFields<T extends Record<string, any>>(
|
||||
obj: T
|
||||
): obj is { [K in keyof T]-?: Exclude<T[K], undefined> } {
|
||||
return Object.values(obj).every((value) => value !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @description Verifica si un objeto tiene campos con valor undefined.
|
||||
* Esta función es el complemento de `hasNoUndefinedFields`.
|
||||
*
|
||||
* @example
|
||||
* const obj = { a: 1, b: 'test', c: null };
|
||||
* console.log(hasUndefinedFields(obj)); // false
|
||||
*
|
||||
* const objWithUndefined = { a: 1, b: undefined, c: null };
|
||||
* console.log(hasUndefinedFields(objWithUndefined)); // true
|
||||
*
|
||||
* @template T - El tipo del objeto.
|
||||
* @param obj - El objeto a evaluar.
|
||||
* @returns true si el objeto tiene al menos un campo undefined, false en caso contrario.
|
||||
*
|
||||
*/
|
||||
|
||||
export function hasUndefinedFields<T extends Record<string, any>>(
|
||||
obj: T
|
||||
): obj is { [K in keyof T]-?: Exclude<T[K], undefined> } {
|
||||
return !hasNoUndefinedFields(obj);
|
||||
}
|
||||
1
modules/customers/src/api/application/helpers/index.ts
Normal file
1
modules/customers/src/api/application/helpers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./map-dto-to-customer-props";
|
||||
@ -0,0 +1,83 @@
|
||||
import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
|
||||
import { CreateCustomerCommandDTO } from "@erp/customers/common/dto";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import {
|
||||
CustomerItem,
|
||||
CustomerItemDescription,
|
||||
CustomerItemDiscount,
|
||||
CustomerItemQuantity,
|
||||
CustomerItemUnitPrice,
|
||||
} from "../../domain";
|
||||
import { extractOrPushError } from "./extract-or-push-error";
|
||||
import { hasNoUndefinedFields } from "./has-no-undefined-fields";
|
||||
|
||||
export function mapDTOToCustomerItemsProps(
|
||||
dtoItems: Pick<CreateCustomerCommandDTO, "items">["items"]
|
||||
): Result<CustomerItem[], ValidationErrorCollection> {
|
||||
const errors: ValidationErrorDetail[] = [];
|
||||
const items: CustomerItem[] = [];
|
||||
|
||||
dtoItems.forEach((item, index) => {
|
||||
const path = (field: string) => `items[${index}].${field}`;
|
||||
|
||||
const description = extractOrPushError(
|
||||
CustomerItemDescription.create(item.description),
|
||||
path("description"),
|
||||
errors
|
||||
);
|
||||
|
||||
const quantity = extractOrPushError(
|
||||
CustomerItemQuantity.create({
|
||||
amount: item.quantity.amount,
|
||||
scale: item.quantity.scale,
|
||||
}),
|
||||
path("quantity"),
|
||||
errors
|
||||
);
|
||||
|
||||
const unitPrice = extractOrPushError(
|
||||
CustomerItemUnitPrice.create({
|
||||
amount: item.unitPrice.amount,
|
||||
scale: item.unitPrice.scale,
|
||||
currency_code: item.unitPrice.currency,
|
||||
}),
|
||||
path("unit_price"),
|
||||
errors
|
||||
);
|
||||
|
||||
const discount = extractOrPushError(
|
||||
CustomerItemDiscount.create({
|
||||
amount: item.discount.amount,
|
||||
scale: item.discount.scale,
|
||||
}),
|
||||
path("discount"),
|
||||
errors
|
||||
);
|
||||
|
||||
if (errors.length === 0) {
|
||||
const itemProps = {
|
||||
description: description,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice,
|
||||
discount: discount,
|
||||
};
|
||||
|
||||
if (hasNoUndefinedFields(itemProps)) {
|
||||
// Validar y crear el item de factura
|
||||
const itemOrError = CustomerItem.create(itemProps);
|
||||
|
||||
if (itemOrError.isSuccess) {
|
||||
items.push(itemOrError.data);
|
||||
} else {
|
||||
errors.push({ path: `items[${index}]`, message: itemOrError.error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Result.fail(new ValidationErrorCollection(errors));
|
||||
}
|
||||
});
|
||||
|
||||
return Result.ok(items);
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
|
||||
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { CreateCustomerCommandDTO } from "../../../common/dto";
|
||||
import { CustomerNumber, CustomerProps, CustomerSerie, CustomerStatus } from "../../domain";
|
||||
import { extractOrPushError } from "./extract-or-push-error";
|
||||
import { mapDTOToCustomerItemsProps } from "./map-dto-to-customer-items-props";
|
||||
|
||||
/**
|
||||
* Convierte el DTO a las props validadas (CustomerProps).
|
||||
* No construye directamente el agregado.
|
||||
*
|
||||
* @param dto - DTO con los datos de la factura de cliente
|
||||
* @returns
|
||||
|
||||
*
|
||||
*/
|
||||
|
||||
export function mapDTOToCustomerProps(dto: CreateCustomerCommandDTO) {
|
||||
const errors: ValidationErrorDetail[] = [];
|
||||
|
||||
const invoiceId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
|
||||
|
||||
const invoiceNumber = extractOrPushError(
|
||||
CustomerNumber.create(dto.invoice_number),
|
||||
"invoice_number",
|
||||
errors
|
||||
);
|
||||
const invoiceSeries = extractOrPushError(
|
||||
CustomerSerie.create(dto.invoice_series),
|
||||
"invoice_series",
|
||||
errors
|
||||
);
|
||||
const issueDate = extractOrPushError(UtcDate.createFromISO(dto.issue_date), "issue_date", errors);
|
||||
const operationDate = extractOrPushError(
|
||||
UtcDate.createFromISO(dto.operation_date),
|
||||
"operation_date",
|
||||
errors
|
||||
);
|
||||
|
||||
//const currency = extractOrPushError(Currency.(dto.currency), "currency", errors);
|
||||
const currency = dto.currency;
|
||||
|
||||
// 🔄 Validar y construir los items de factura con helper especializado
|
||||
const itemsResult = mapDTOToCustomerItemsProps(dto.items);
|
||||
if (itemsResult.isFailure) {
|
||||
return Result.fail(itemsResult.error);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return Result.fail(new ValidationErrorCollection(errors));
|
||||
}
|
||||
|
||||
const invoiceProps: CustomerProps = {
|
||||
invoiceNumber: invoiceNumber!,
|
||||
invoiceSeries: invoiceSeries!,
|
||||
issueDate: issueDate!,
|
||||
operationDate: operationDate!,
|
||||
status: CustomerStatus.createDraft(),
|
||||
currency,
|
||||
};
|
||||
|
||||
return Result.ok({ id: invoiceId!, props: invoiceProps });
|
||||
|
||||
/*if (hasNoUndefinedFields(invoiceProps)) {
|
||||
const invoiceOrError = Customer.create(invoiceProps, invoiceId);
|
||||
if (invoiceOrError.isFailure) {
|
||||
return Result.fail(invoiceOrError.error);
|
||||
}
|
||||
return Result.ok(invoiceOrError.data);
|
||||
}
|
||||
|
||||
return Result.fail(
|
||||
new ValidationErrorCollection([
|
||||
{ path: "", message: "Error building from DTO: Some fields are undefined" },
|
||||
])
|
||||
);*/
|
||||
}
|
||||
5
modules/customers/src/api/application/index.ts
Normal file
5
modules/customers/src/api/application/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./create-customer";
|
||||
export * from "./delete-customer";
|
||||
export * from "./get-customer";
|
||||
export * from "./list-customers";
|
||||
//export * from "./update-customer";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./list-customers.use-case";
|
||||
@ -0,0 +1,33 @@
|
||||
import { ITransactionManager } from "@erp/core/api";
|
||||
import { ListCustomersResultDTO } from "@erp/customers/common/dto";
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { Transaction } from "sequelize";
|
||||
import { ICustomerService } from "../../domain";
|
||||
import { ListCustomersPresenter } from "./presenter";
|
||||
|
||||
export class ListCustomersUseCase {
|
||||
constructor(
|
||||
private readonly customerService: ICustomerService,
|
||||
private readonly transactionManager: ITransactionManager,
|
||||
private readonly presenter: ListCustomersPresenter
|
||||
) {}
|
||||
|
||||
public execute(criteria: Criteria): Promise<Result<ListCustomersResultDTO, Error>> {
|
||||
return this.transactionManager.complete(async (transaction: Transaction) => {
|
||||
try {
|
||||
const result = await this.customerService.findByCriteria(criteria, transaction);
|
||||
|
||||
if (result.isFailure) {
|
||||
console.error(result.error);
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
const dto: ListCustomersResultDTO = this.presenter.toDTO(result.data, criteria);
|
||||
return Result.ok(dto);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./list-invoices.presenter";
|
||||
@ -0,0 +1,54 @@
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { Collection } from "@repo/rdx-utils";
|
||||
import { CustomerListResponsetDTO } from "../../../../common/dto";
|
||||
import { Customer } from "../../../domain";
|
||||
|
||||
export interface ListCustomersPresenter {
|
||||
toDTO: (customers: Collection<Customer>, criteria: Criteria) => CustomerListResponsetDTO;
|
||||
}
|
||||
|
||||
export const listCustomersPresenter: ListCustomersPresenter = {
|
||||
toDTO: (customers: Collection<Customer>, criteria: Criteria): CustomerListResponsetDTO => {
|
||||
const items = customers.map((invoice) => {
|
||||
return {
|
||||
id: invoice.id.toPrimitive(),
|
||||
|
||||
invoice_status: invoice.status.toString(),
|
||||
invoice_number: invoice.invoiceNumber.toString(),
|
||||
invoice_series: invoice.invoiceSeries.toString(),
|
||||
issue_date: invoice.issueDate.toISOString(),
|
||||
operation_date: invoice.operationDate.toISOString(),
|
||||
language_code: "ES",
|
||||
currency: "EUR",
|
||||
|
||||
subtotal_price: invoice.calculateSubtotal().toPrimitive(),
|
||||
total_price: invoice.calculateTotal().toPrimitive(),
|
||||
|
||||
//recipient: CustomerParticipantPresenter(customer.recipient),
|
||||
|
||||
metadata: {
|
||||
entity: "customer",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const totalItems = customers.total();
|
||||
|
||||
return {
|
||||
page: criteria.pageNumber,
|
||||
per_page: criteria.pageSize,
|
||||
total_pages: Math.ceil(totalItems / criteria.pageSize),
|
||||
total_items: totalItems,
|
||||
items: items,
|
||||
metadata: {
|
||||
entity: "customers",
|
||||
criteria: criteria.toJSON(),
|
||||
//links: {
|
||||
// self: `/api/customers?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,
|
||||
// first: `/api/customers?page=1&per_page=${criteria.pageSize}`,
|
||||
// last: `/api/customers?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`,
|
||||
//},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
2
modules/customers/src/api/application/services/index.ts
Normal file
2
modules/customers/src/api/application/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
//export * from "./participantAddressFinder";
|
||||
//export * from "./participantFinder";
|
||||
@ -0,0 +1,64 @@
|
||||
/* import {
|
||||
ApplicationServiceError,
|
||||
type IApplicationServiceError,
|
||||
} from "@/contexts/common/application/services/ApplicationServiceError";
|
||||
import { IAdapter, RepositoryBuilder } from "@/contexts/common/domain";
|
||||
import { Result, UniqueID } from "@shared/contexts";
|
||||
import { NullOr } from "@shared/utilities";
|
||||
import { ICustomerParticipantAddress, ICustomerParticipantAddressRepository } from "../../domain";
|
||||
|
||||
export const participantAddressFinder = async (
|
||||
addressId: UniqueID,
|
||||
adapter: IAdapter,
|
||||
repository: RepositoryBuilder<ICustomerParticipantAddressRepository>
|
||||
) => {
|
||||
if (addressId.isNull()) {
|
||||
return Result.fail<IApplicationServiceError>(
|
||||
ApplicationServiceError.create(
|
||||
ApplicationServiceError.INVALID_REQUEST_PARAM,
|
||||
`Participant address ID required`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const transaction = adapter.startTransaction();
|
||||
let address: NullOr<ICustomerParticipantAddress> = null;
|
||||
|
||||
try {
|
||||
await transaction.complete(async (t) => {
|
||||
address = await repository({ transaction: t }).getById(addressId);
|
||||
});
|
||||
|
||||
if (address === null) {
|
||||
return Result.fail<IApplicationServiceError>(
|
||||
ApplicationServiceError.create(ApplicationServiceError.NOT_FOUND_ERROR, "", {
|
||||
id: addressId.toString(),
|
||||
entity: "participant address",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok<ICustomerParticipantAddress>(address);
|
||||
} catch (error: unknown) {
|
||||
const _error = error as Error;
|
||||
|
||||
if (repository().isRepositoryError(_error)) {
|
||||
return Result.fail<IApplicationServiceError>(
|
||||
ApplicationServiceError.create(
|
||||
ApplicationServiceError.REPOSITORY_ERROR,
|
||||
_error.message,
|
||||
_error
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.fail<IApplicationServiceError>(
|
||||
ApplicationServiceError.create(
|
||||
ApplicationServiceError.UNEXCEPTED_ERROR,
|
||||
_error.message,
|
||||
_error
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
*/
|
||||
@ -0,0 +1,21 @@
|
||||
/* import { IAdapter, RepositoryBuilder } from "@/contexts/common/domain";
|
||||
import { UniqueID } from "@shared/contexts";
|
||||
import { ICustomerParticipantRepository } from "../../domain";
|
||||
import { CustomerCustomer } from "../../domain/entities/customer-customer/customer-customer";
|
||||
|
||||
export const participantFinder = async (
|
||||
participantId: UniqueID,
|
||||
adapter: IAdapter,
|
||||
repository: RepositoryBuilder<ICustomerParticipantRepository>
|
||||
): Promise<CustomerCustomer | undefined> => {
|
||||
if (!participantId || (participantId && participantId.isNull())) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const participant = await adapter
|
||||
.startTransaction()
|
||||
.complete((t) => repository({ transaction: t }).getById(participantId));
|
||||
|
||||
return Promise.resolve(participant ? participant : undefined);
|
||||
};
|
||||
*/
|
||||
@ -0,0 +1 @@
|
||||
export * from "./update-customer.use-case";
|
||||
@ -0,0 +1,401 @@
|
||||
import { UniqueID } from "@/core/common/domain";
|
||||
import { ITransactionManager } from "@/core/common/infrastructure/database";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { IUpdateCustomerRequestDTO } from "../../common/dto";
|
||||
import { Customer, ICustomerService } from "../domain";
|
||||
|
||||
export class CreateCustomerUseCase {
|
||||
constructor(
|
||||
private readonly customerService: ICustomerService,
|
||||
private readonly transactionManager: ITransactionManager
|
||||
) {}
|
||||
|
||||
public execute(
|
||||
customerID: UniqueID,
|
||||
dto: Partial<IUpdateCustomerRequestDTO>
|
||||
): Promise<Result<Customer, Error>> {
|
||||
return this.transactionManager.complete(async (transaction) => {
|
||||
return Result.fail(new Error("No implementado"));
|
||||
/*
|
||||
try {
|
||||
const validOrErrors = this.validateCustomerData(dto);
|
||||
if (validOrErrors.isFailure) {
|
||||
return Result.fail(validOrErrors.error);
|
||||
}
|
||||
|
||||
const data = validOrErrors.data;
|
||||
|
||||
// Update customer with dto
|
||||
return await this.customerService.updateCustomerById(customerID, data, transaction);
|
||||
} catch (error: unknown) {
|
||||
logger.error(error as Error);
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
*/
|
||||
});
|
||||
}
|
||||
|
||||
/* private validateCustomerData(
|
||||
dto: Partial<IUpdateCustomerRequestDTO>
|
||||
): Result<Partial<ICustomerProps>, Error> {
|
||||
const errors: Error[] = [];
|
||||
const validatedData: Partial<ICustomerProps> = {};
|
||||
|
||||
// Create customer
|
||||
let customer_status = CustomerStatus.create(customerDTO.status).object;
|
||||
if (customer_status.isEmpty()) {
|
||||
customer_status = CustomerStatus.createDraft();
|
||||
}
|
||||
|
||||
let customer_series = CustomerSeries.create(customerDTO.customer_series).object;
|
||||
if (customer_series.isEmpty()) {
|
||||
customer_series = CustomerSeries.create(customerDTO.customer_series).object;
|
||||
}
|
||||
|
||||
let issue_date = CustomerDate.create(customerDTO.issue_date).object;
|
||||
if (issue_date.isEmpty()) {
|
||||
issue_date = CustomerDate.createCurrentDate().object;
|
||||
}
|
||||
|
||||
let operation_date = CustomerDate.create(customerDTO.operation_date).object;
|
||||
if (operation_date.isEmpty()) {
|
||||
operation_date = CustomerDate.createCurrentDate().object;
|
||||
}
|
||||
|
||||
let customerCurrency = Currency.createFromCode(customerDTO.currency).object;
|
||||
|
||||
if (customerCurrency.isEmpty()) {
|
||||
customerCurrency = Currency.createDefaultCode().object;
|
||||
}
|
||||
|
||||
let customerLanguage = Language.createFromCode(customerDTO.language_code).object;
|
||||
|
||||
if (customerLanguage.isEmpty()) {
|
||||
customerLanguage = Language.createDefaultCode().object;
|
||||
}
|
||||
|
||||
const items = new Collection<CustomerItem>(
|
||||
customerDTO.items?.map(
|
||||
(item) =>
|
||||
CustomerSimpleItem.create({
|
||||
description: Description.create(item.description).object,
|
||||
quantity: Quantity.create(item.quantity).object,
|
||||
unitPrice: UnitPrice.create({
|
||||
amount: item.unit_price.amount,
|
||||
currencyCode: item.unit_price.currency,
|
||||
precision: item.unit_price.precision,
|
||||
}).object,
|
||||
}).object
|
||||
)
|
||||
);
|
||||
|
||||
if (!customer_status.isDraft()) {
|
||||
throw Error("Error al crear una factura que no es borrador");
|
||||
}
|
||||
|
||||
return DraftCustomer.create(
|
||||
{
|
||||
customerSeries: customer_series,
|
||||
issueDate: issue_date,
|
||||
operationDate: operation_date,
|
||||
customerCurrency,
|
||||
language: customerLanguage,
|
||||
customerNumber: CustomerNumber.create(undefined).object,
|
||||
//notes: Note.create(customerDTO.notes).object,
|
||||
|
||||
//senderId: UniqueID.create(null).object,
|
||||
recipient,
|
||||
|
||||
items,
|
||||
},
|
||||
customerId
|
||||
);
|
||||
} */
|
||||
}
|
||||
|
||||
/* export type UpdateCustomerResponseOrError =
|
||||
| Result<never, IUseCaseError> // Misc errors (value objects)
|
||||
| Result<Customer, never>; // Success!
|
||||
|
||||
export class UpdateCustomerUseCase2
|
||||
implements
|
||||
IUseCase<{ id: UniqueID; data: IUpdateCustomer_DTO }, Promise<UpdateCustomerResponseOrError>>
|
||||
{
|
||||
private _context: IInvoicingContext;
|
||||
private _adapter: ISequelizeAdapter;
|
||||
private _repositoryManager: IRepositoryManager;
|
||||
|
||||
constructor(context: IInvoicingContext) {
|
||||
this._context = context;
|
||||
this._adapter = context.adapter;
|
||||
this._repositoryManager = context.repositoryManager;
|
||||
}
|
||||
|
||||
private getRepository<T>(name: string) {
|
||||
return this._repositoryManager.getRepository<T>(name);
|
||||
}
|
||||
|
||||
private handleValidationFailure(
|
||||
validationError: Error,
|
||||
message?: string
|
||||
): Result<never, IUseCaseError> {
|
||||
return Result.fail<IUseCaseError>(
|
||||
UseCaseError.create(
|
||||
UseCaseError.INVALID_INPUT_DATA,
|
||||
message ? message : validationError.message,
|
||||
validationError
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async execute(request: {
|
||||
id: UniqueID;
|
||||
data: IUpdateCustomer_DTO;
|
||||
}): Promise<UpdateCustomerResponseOrError> {
|
||||
const { id, data: customerDTO } = request;
|
||||
|
||||
// Validaciones
|
||||
const customerDTOOrError = ensureUpdateCustomer_DTOIsValid(customerDTO);
|
||||
if (customerDTOOrError.isFailure) {
|
||||
return this.handleValidationFailure(customerDTOOrError.error);
|
||||
}
|
||||
|
||||
const transaction = this._adapter.startTransaction();
|
||||
|
||||
const customerRepoBuilder = this.getRepository<ICustomerRepository>("Customer");
|
||||
|
||||
let customer: Customer | null = null;
|
||||
|
||||
try {
|
||||
await transaction.complete(async (t) => {
|
||||
customer = await customerRepoBuilder({ transaction: t }).getById(id);
|
||||
});
|
||||
|
||||
if (customer === null) {
|
||||
return Result.fail<IUseCaseError>(
|
||||
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, `Customer not found`, {
|
||||
id: request.id.toString(),
|
||||
entity: "customer",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok<Customer>(customer);
|
||||
} catch (error: unknown) {
|
||||
const _error = error as Error;
|
||||
if (customerRepoBuilder().isRepositoryError(_error)) {
|
||||
return this.handleRepositoryError(error as BaseError, customerRepoBuilder());
|
||||
} else {
|
||||
return this.handleUnexceptedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Recipient validations
|
||||
const recipientIdOrError = ensureParticipantIdIsValid(
|
||||
customerDTO?.recipient?.id,
|
||||
);
|
||||
if (recipientIdOrError.isFailure) {
|
||||
return this.handleValidationFailure(
|
||||
recipientIdOrError.error,
|
||||
"Recipient ID not valid",
|
||||
);
|
||||
}
|
||||
const recipientId = recipientIdOrError.object;
|
||||
|
||||
const recipientBillingIdOrError = ensureParticipantAddressIdIsValid(
|
||||
customerDTO?.recipient?.billing_address_id,
|
||||
);
|
||||
if (recipientBillingIdOrError.isFailure) {
|
||||
return this.handleValidationFailure(
|
||||
recipientBillingIdOrError.error,
|
||||
"Recipient billing address ID not valid",
|
||||
);
|
||||
}
|
||||
const recipientBillingId = recipientBillingIdOrError.object;
|
||||
|
||||
const recipientShippingIdOrError = ensureParticipantAddressIdIsValid(
|
||||
customerDTO?.recipient?.shipping_address_id,
|
||||
);
|
||||
if (recipientShippingIdOrError.isFailure) {
|
||||
return this.handleValidationFailure(
|
||||
recipientShippingIdOrError.error,
|
||||
"Recipient shipping address ID not valid",
|
||||
);
|
||||
}
|
||||
const recipientShippingId = recipientShippingIdOrError.object;
|
||||
|
||||
const recipientContact = await this.findContact(
|
||||
recipientId,
|
||||
recipientBillingId,
|
||||
recipientShippingId,
|
||||
);
|
||||
|
||||
if (!recipientContact) {
|
||||
return this.handleValidationFailure(
|
||||
new Error(`Recipient with ID ${recipientId.toString()} does not exist`),
|
||||
);
|
||||
}
|
||||
|
||||
// Crear customer
|
||||
const customerOrError = await this.tryUpdateCustomerInstance(
|
||||
customerDTO,
|
||||
customerIdOrError.object,
|
||||
//senderId,
|
||||
//senderBillingId,
|
||||
//senderShippingId,
|
||||
recipientContact,
|
||||
);
|
||||
|
||||
if (customerOrError.isFailure) {
|
||||
const { error: domainError } = customerOrError;
|
||||
let errorCode = "";
|
||||
let message = "";
|
||||
|
||||
switch (domainError.code) {
|
||||
case Customer.ERROR_CUSTOMER_WITHOUT_NAME:
|
||||
errorCode = UseCaseError.INVALID_INPUT_DATA;
|
||||
message =
|
||||
"El cliente debe ser una compañía o tener nombre y apellidos.";
|
||||
break;
|
||||
|
||||
default:
|
||||
errorCode = UseCaseError.UNEXCEPTED_ERROR;
|
||||
message = "";
|
||||
break;
|
||||
}
|
||||
|
||||
return Result.fail<IUseCaseError>(
|
||||
UseCaseError.create(errorCode, message, domainError),
|
||||
);
|
||||
}
|
||||
|
||||
return this.saveCustomer(customerOrError.object);
|
||||
|
||||
}
|
||||
|
||||
private async tryUpdateCustomerInstance(customerDTO, customerId, recipient) {
|
||||
// Create customer
|
||||
let customer_status = CustomerStatus.create(customerDTO.status).object;
|
||||
if (customer_status.isEmpty()) {
|
||||
customer_status = CustomerStatus.createDraft();
|
||||
}
|
||||
|
||||
let customer_series = CustomerSeries.create(customerDTO.customer_series).object;
|
||||
if (customer_series.isEmpty()) {
|
||||
customer_series = CustomerSeries.create(customerDTO.customer_series).object;
|
||||
}
|
||||
|
||||
let issue_date = CustomerDate.create(customerDTO.issue_date).object;
|
||||
if (issue_date.isEmpty()) {
|
||||
issue_date = CustomerDate.createCurrentDate().object;
|
||||
}
|
||||
|
||||
let operation_date = CustomerDate.create(customerDTO.operation_date).object;
|
||||
if (operation_date.isEmpty()) {
|
||||
operation_date = CustomerDate.createCurrentDate().object;
|
||||
}
|
||||
|
||||
let customerCurrency = Currency.createFromCode(customerDTO.currency).object;
|
||||
|
||||
if (customerCurrency.isEmpty()) {
|
||||
customerCurrency = Currency.createDefaultCode().object;
|
||||
}
|
||||
|
||||
let customerLanguage = Language.createFromCode(customerDTO.language_code).object;
|
||||
|
||||
if (customerLanguage.isEmpty()) {
|
||||
customerLanguage = Language.createDefaultCode().object;
|
||||
}
|
||||
|
||||
const items = new Collection<CustomerItem>(
|
||||
customerDTO.items?.map(
|
||||
(item) =>
|
||||
CustomerSimpleItem.create({
|
||||
description: Description.create(item.description).object,
|
||||
quantity: Quantity.create(item.quantity).object,
|
||||
unitPrice: UnitPrice.create({
|
||||
amount: item.unit_price.amount,
|
||||
currencyCode: item.unit_price.currency,
|
||||
precision: item.unit_price.precision,
|
||||
}).object,
|
||||
}).object
|
||||
)
|
||||
);
|
||||
|
||||
if (!customer_status.isDraft()) {
|
||||
throw Error("Error al crear una factura que no es borrador");
|
||||
}
|
||||
|
||||
return DraftCustomer.create(
|
||||
{
|
||||
customerSeries: customer_series,
|
||||
issueDate: issue_date,
|
||||
operationDate: operation_date,
|
||||
customerCurrency,
|
||||
language: customerLanguage,
|
||||
customerNumber: CustomerNumber.create(undefined).object,
|
||||
//notes: Note.create(customerDTO.notes).object,
|
||||
|
||||
//senderId: UniqueID.create(null).object,
|
||||
recipient,
|
||||
|
||||
items,
|
||||
},
|
||||
customerId
|
||||
);
|
||||
}
|
||||
|
||||
private async findContact(
|
||||
contactId: UniqueID,
|
||||
billingAddressId: UniqueID,
|
||||
shippingAddressId: UniqueID
|
||||
) {
|
||||
const contactRepoBuilder = this.getRepository<IContactRepository>("Contact");
|
||||
|
||||
const contact = await contactRepoBuilder().getById2(
|
||||
contactId,
|
||||
billingAddressId,
|
||||
shippingAddressId
|
||||
);
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
private async saveCustomer(customer: DraftCustomer) {
|
||||
const transaction = this._adapter.startTransaction();
|
||||
const customerRepoBuilder = this.getRepository<ICustomerRepository>("Customer");
|
||||
|
||||
try {
|
||||
await transaction.complete(async (t) => {
|
||||
const customerRepo = customerRepoBuilder({ transaction: t });
|
||||
await customerRepo.save(customer);
|
||||
});
|
||||
|
||||
return Result.ok<DraftCustomer>(customer);
|
||||
} catch (error: unknown) {
|
||||
const _error = error as Error;
|
||||
if (customerRepoBuilder().isRepositoryError(_error)) {
|
||||
return this.handleRepositoryError(error as BaseError, customerRepoBuilder());
|
||||
} else {
|
||||
return this.handleUnexceptedError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleUnexceptedError(error): Result<never, IUseCaseError> {
|
||||
return Result.fail<IUseCaseError>(
|
||||
UseCaseError.create(UseCaseError.UNEXCEPTED_ERROR, error.message, error)
|
||||
);
|
||||
}
|
||||
|
||||
private handleRepositoryError(
|
||||
error: BaseError,
|
||||
repository: ICustomerRepository
|
||||
): Result<never, IUseCaseError> {
|
||||
const { message, details } = repository.handleRepositoryError(error);
|
||||
return Result.fail<IUseCaseError>(
|
||||
UseCaseError.create(UseCaseError.REPOSITORY_ERROR, message, details)
|
||||
);
|
||||
}
|
||||
}
|
||||
*/
|
||||
@ -0,0 +1,34 @@
|
||||
import { ExpressController, errorMapper } from "@erp/core/api";
|
||||
import { CreateCustomerCommandDTO } from "../../../common/dto";
|
||||
import { CreateCustomerUseCase } from "../../application";
|
||||
|
||||
export class CreateCustomerController extends ExpressController {
|
||||
public constructor(private readonly createCustomer: CreateCustomerUseCase) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const dto = this.req.body as CreateCustomerCommandDTO;
|
||||
/*
|
||||
const user = this.req.user; // asumimos middleware authenticateJWT inyecta user
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
this.unauthorized(res, "Unauthorized: user or company not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Inyectar empresa del usuario autenticado (ownership)
|
||||
dto.customerCompanyId = user.companyId;
|
||||
*/
|
||||
|
||||
const result = await this.createCustomer.execute(dto);
|
||||
|
||||
if (result.isFailure) {
|
||||
console.log(result.error);
|
||||
const apiError = errorMapper.toApiError(result.error);
|
||||
return this.handleApiError(apiError);
|
||||
}
|
||||
|
||||
return this.created(result.data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { SequelizeTransactionManager } from "@erp/core/api";
|
||||
import { Sequelize } from "sequelize";
|
||||
import { CreateCustomerUseCase, CreateCustomersPresenter } from "../../application/";
|
||||
import { CustomerService } from "../../domain";
|
||||
import { CustomerMapper } from "../../infrastructure";
|
||||
import { CreateCustomerController } from "./create-customer";
|
||||
|
||||
export const buildCreateCustomersController = (database: Sequelize) => {
|
||||
const transactionManager = new SequelizeTransactionManager(database);
|
||||
const customerRepository = new customerRepository(database, new CustomerMapper());
|
||||
const customerService = new CustomerService(customerRepository);
|
||||
const presenter = new CreateCustomersPresenter();
|
||||
|
||||
const useCase = new CreateCustomerUseCase(customerService, transactionManager, presenter);
|
||||
|
||||
return new CreateCustomerController(useCase);
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { ExpressController, errorMapper } from "@erp/core/api";
|
||||
import { DeleteCustomerUseCase } from "../../application";
|
||||
|
||||
export class DeleteCustomerController extends ExpressController {
|
||||
public constructor(private readonly deleteCustomer: DeleteCustomerUseCase) {
|
||||
super();
|
||||
}
|
||||
|
||||
async executeImpl(): Promise<any> {
|
||||
const { id } = this.req.params;
|
||||
|
||||
/*
|
||||
const user = this.req.user; // asumimos middleware authenticateJWT inyecta user
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
this.unauthorized(res, "Unauthorized: user or company not found");
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
const result = await this.deleteCustomer.execute({ id });
|
||||
|
||||
if (result.isFailure) {
|
||||
const apiError = errorMapper.toApiError(result.error);
|
||||
return this.handleApiError(apiError);
|
||||
}
|
||||
|
||||
return this.ok(result.data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { SequelizeTransactionManager } from "@erp/core/api";
|
||||
import { Sequelize } from "sequelize";
|
||||
import { DeleteCustomerUseCase } from "../../application";
|
||||
import { CustomerService } from "../../domain";
|
||||
import { CustomerMapper } from "../../infrastructure";
|
||||
import { DeleteCustomerController } from "./delete-invoice.controller";
|
||||
|
||||
export const buildDeleteCustomerController = (database: Sequelize) => {
|
||||
const transactionManager = new SequelizeTransactionManager(database);
|
||||
const customerRepository = new customerRepository(database, new CustomerMapper());
|
||||
const customerService = new CustomerService(customerRepository);
|
||||
|
||||
const useCase = new DeleteCustomerUseCase(customerService, transactionManager);
|
||||
|
||||
return new DeleteCustomerController(useCase);
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { ExpressController, errorMapper } from "@erp/core/api";
|
||||
import { GetCustomerUseCase } from "../../application";
|
||||
|
||||
export class GetCustomerController extends ExpressController {
|
||||
public constructor(private readonly getCustomer: GetCustomerUseCase) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const { id } = this.req.params;
|
||||
|
||||
/*
|
||||
const user = this.req.user; // asumimos middleware authenticateJWT inyecta user
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
this.unauthorized(res, "Unauthorized: user or company not found");
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
const result = await this.getCustomer.execute({ id });
|
||||
|
||||
if (result.isFailure) {
|
||||
const apiError = errorMapper.toApiError(result.error);
|
||||
return this.handleApiError(apiError);
|
||||
}
|
||||
|
||||
return this.ok(result.data);
|
||||
}
|
||||
}
|
||||
17
modules/customers/src/api/controllers/get-customer/index.ts
Normal file
17
modules/customers/src/api/controllers/get-customer/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { SequelizeTransactionManager } from "@erp/core/api";
|
||||
import { Sequelize } from "sequelize";
|
||||
import { GetCustomerUseCase, getCustomerPresenter } from "../../application";
|
||||
import { CustomerService } from "../../domain";
|
||||
import { customerMapper } from "../../infrastructure";
|
||||
import { GetCustomerController } from "./get-invoice.controller";
|
||||
|
||||
export const buildGetCustomerController = (database: Sequelize) => {
|
||||
const transactionManager = new SequelizeTransactionManager(database);
|
||||
const repository = new CustomerRepository(database, customerMapper);
|
||||
const customerService = new CustomerService(customerRepository);
|
||||
const presenter = getCustomerPresenter;
|
||||
|
||||
const useCase = new GetCustomerUseCase(customerService, transactionManager, presenter);
|
||||
|
||||
return new GetCustomerController(useCase);
|
||||
};
|
||||
5
modules/customers/src/api/controllers/index.ts
Normal file
5
modules/customers/src/api/controllers/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./create-customer";
|
||||
export * from "./delete-customer";
|
||||
export * from "./get-customer";
|
||||
export * from "./list-customers";
|
||||
///export * from "./update-customer";
|
||||
@ -0,0 +1,18 @@
|
||||
import { SequelizeTransactionManager } from "@erp/core/api";
|
||||
import { Sequelize } from "sequelize";
|
||||
import { ListCustomersUseCase } from "../../application";
|
||||
import { listCustomersPresenter } from "../../application/list-customers/presenter";
|
||||
import { CustomerService } from "../../domain";
|
||||
import { CustomerRepository, customerMapper } from "../../infrastructure";
|
||||
import { ListCustomersController } from "./list-customers.controller";
|
||||
|
||||
export const buildListCustomersController = (database: Sequelize) => {
|
||||
const transactionManager = new SequelizeTransactionManager(database);
|
||||
const repository = new CustomerRepository(database, customerMapper);
|
||||
const customerService = new CustomerService(repository);
|
||||
const presenter = listCustomersPresenter;
|
||||
|
||||
const useCase = new ListCustomersUseCase(customerService, transactionManager, presenter);
|
||||
|
||||
return new ListCustomersController(useCase);
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { ExpressController, errorMapper } from "@erp/core/api";
|
||||
import { ListCustomersUseCase } from "../../application";
|
||||
|
||||
export class ListCustomersController extends ExpressController {
|
||||
public constructor(private readonly listCustomers: ListCustomersUseCase) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const criteria = this.criteria;
|
||||
|
||||
/*
|
||||
const user = this.req.user; // asumimos middleware authenticateJWT inyecta user
|
||||
|
||||
if (!user || !user.companyId) {
|
||||
this.unauthorized(res, "Unauthorized: user or company not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Inyectar empresa del usuario autenticado (ownership)
|
||||
this.criteria.addFilter("companyId", "=", companyId);
|
||||
*/
|
||||
|
||||
const result = await this.listCustomers.execute(criteria);
|
||||
|
||||
if (result.isFailure) {
|
||||
const apiError = errorMapper.toApiError(result.error);
|
||||
return this.handleApiError(apiError);
|
||||
}
|
||||
|
||||
return this.ok(result.data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { IInvoicingContext } from "#/server/intrastructure";
|
||||
import { CustomerRepository } from "#/server/intrastructure/Customer.repository";
|
||||
|
||||
export const updateCustomerController = (context: IInvoicingContext) => {
|
||||
const adapter = context.adapter;
|
||||
const repoManager = context.repositoryManager;
|
||||
|
||||
repoManager.registerRepository("Customer", (params = { transaction: null }) => {
|
||||
const { transaction } = params;
|
||||
|
||||
return new CustomerRepository({
|
||||
transaction,
|
||||
adapter,
|
||||
mapper: createCustomerMapper(context),
|
||||
});
|
||||
});
|
||||
|
||||
repoManager.registerRepository("Participant", (params = { transaction: null }) => {
|
||||
const { transaction } = params;
|
||||
|
||||
return new CustomerParticipantRepository({
|
||||
transaction,
|
||||
adapter,
|
||||
mapper: createCustomerParticipantMapper(context),
|
||||
});
|
||||
});
|
||||
|
||||
repoManager.registerRepository("ParticipantAddress", (params = { transaction: null }) => {
|
||||
const { transaction } = params;
|
||||
|
||||
return new CustomerParticipantAddressRepository({
|
||||
transaction,
|
||||
adapter,
|
||||
mapper: createCustomerParticipantAddressMapper(context),
|
||||
});
|
||||
});
|
||||
|
||||
repoManager.registerRepository("Contact", (params = { transaction: null }) => {
|
||||
const { transaction } = params;
|
||||
|
||||
return new ContactRepository({
|
||||
transaction,
|
||||
adapter,
|
||||
mapper: createContactMapper(context),
|
||||
});
|
||||
});
|
||||
|
||||
const updateCustomerUseCase = new UpdateCustomerUseCase(context);
|
||||
|
||||
return new UpdateCustomerController(
|
||||
{
|
||||
useCase: updateCustomerUseCase,
|
||||
presenter: updateCustomerPresenter,
|
||||
},
|
||||
context
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import { CustomerItem } from "@/contexts/invoicing/domain/CustomerItems";
|
||||
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
|
||||
import { ICollection, IMoney_Response_DTO } from "@shared/contexts";
|
||||
|
||||
export const customerItemPresenter = (
|
||||
items: ICollection<CustomerItem>,
|
||||
context: IInvoicingContext
|
||||
) =>
|
||||
items.totalCount > 0
|
||||
? items.items.map((item: CustomerItem) => ({
|
||||
description: item.description.toString(),
|
||||
quantity: item.quantity.toString(),
|
||||
unit_measure: "",
|
||||
unit_price: item.unitPrice.toPrimitive() as IMoney_Response_DTO,
|
||||
subtotal: item.calculateSubtotal().toPrimitive() as IMoney_Response_DTO,
|
||||
tax_amount: item.calculateTaxAmount().toPrimitive() as IMoney_Response_DTO,
|
||||
total: item.calculateTotal().toPrimitive() as IMoney_Response_DTO,
|
||||
}))
|
||||
: [];
|
||||
@ -0,0 +1,26 @@
|
||||
import { ICustomerParticipant } from "@/contexts/invoicing/domain";
|
||||
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
|
||||
import { IUpdateCustomer_Participant_Response_DTO } from "@shared/contexts";
|
||||
import { CustomerParticipantAddressPresenter } from "./CustomerParticipantAddress.presenter";
|
||||
|
||||
export const CustomerParticipantPresenter = (
|
||||
participant: ICustomerParticipant,
|
||||
context: IInvoicingContext,
|
||||
): IUpdateCustomer_Participant_Response_DTO | undefined => {
|
||||
return {
|
||||
id: participant.id.toString(),
|
||||
tin: participant.tin.toString(),
|
||||
first_name: participant.firstName.toString(),
|
||||
last_name: participant.lastName.toString(),
|
||||
company_name: participant.companyName.toString(),
|
||||
|
||||
billing_address: CustomerParticipantAddressPresenter(
|
||||
participant.billingAddress!,
|
||||
context,
|
||||
),
|
||||
shipping_address: CustomerParticipantAddressPresenter(
|
||||
participant.shippingAddress!,
|
||||
context,
|
||||
),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import { CustomerParticipantAddress } from "@/contexts/invoicing/domain";
|
||||
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
|
||||
import { IUpdateCustomer_AddressParticipant_Response_DTO } from "@shared/contexts";
|
||||
|
||||
export const CustomerParticipantAddressPresenter = (
|
||||
address: CustomerParticipantAddress,
|
||||
context: IInvoicingContext,
|
||||
): IUpdateCustomer_AddressParticipant_Response_DTO => {
|
||||
return {
|
||||
id: address.id.toString(),
|
||||
street: address.street.toString(),
|
||||
city: address.city.toString(),
|
||||
postal_code: address.postalCode.toString(),
|
||||
province: address.province.toString(),
|
||||
country: address.country.toString(),
|
||||
email: address.email.toString(),
|
||||
phone: address.phone.toString(),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { Customer } from "@/contexts/invoicing/domain";
|
||||
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
|
||||
import { IUpdateCustomer_Response_DTO } from "@shared/contexts";
|
||||
import { customerItemPresenter } from "./CustomerItem.presenter";
|
||||
import { CustomerParticipantPresenter } from "./CustomerParticipant.presenter";
|
||||
|
||||
export interface IUpdateCustomerPresenter {
|
||||
map: (customer: Customer, context: IInvoicingContext) => IUpdateCustomer_Response_DTO;
|
||||
}
|
||||
|
||||
export const updateCustomerPresenter: IUpdateCustomerPresenter = {
|
||||
map: (customer: Customer, context: IInvoicingContext): IUpdateCustomer_Response_DTO => {
|
||||
return {
|
||||
id: customer.id.toString(),
|
||||
|
||||
customer_status: customer.status.toString(),
|
||||
customer_number: customer.customerNumber.toString(),
|
||||
customer_series: customer.customerSeries.toString(),
|
||||
issue_date: customer.issueDate.toISO8601(),
|
||||
operation_date: customer.operationDate.toISO8601(),
|
||||
language_code: customer.language.toString(),
|
||||
currency: customer.currency.toString(),
|
||||
subtotal: customer.calculateSubtotal().toPrimitive(),
|
||||
total: customer.calculateTotal().toPrimitive(),
|
||||
|
||||
//sender: {}, //await CustomerParticipantPresenter(customer.senderId, context),
|
||||
|
||||
recipient: CustomerParticipantPresenter(customer.recipient, context),
|
||||
|
||||
items: customerItemPresenter(customer.items, context),
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./UpdateCustomer.presenter";
|
||||
@ -0,0 +1,72 @@
|
||||
import { IInvoicingContext } from "#/server/intrastructure";
|
||||
import { ExpressController } from "@rdx/core";
|
||||
import { IUpdateCustomerPresenter } from "./presenter";
|
||||
|
||||
export class UpdateCustomerController extends ExpressController {
|
||||
private useCase: UpdateCustomerUseCase2;
|
||||
private presenter: IUpdateCustomerPresenter;
|
||||
private context: IInvoicingContext;
|
||||
|
||||
constructor(
|
||||
props: {
|
||||
useCase: UpdateCustomerUseCase;
|
||||
presenter: IUpdateCustomerPresenter;
|
||||
},
|
||||
context: IInvoicingContext
|
||||
) {
|
||||
super();
|
||||
|
||||
const { useCase, presenter } = props;
|
||||
this.useCase = useCase;
|
||||
this.presenter = presenter;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async executeImpl(): Promise<any> {
|
||||
const { customerId } = this.req.params;
|
||||
const request: IUpdateCustomer_DTO = this.req.body;
|
||||
|
||||
if (RuleValidator.validate(RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, customerId).isFailure) {
|
||||
return this.invalidInputError("Customer Id param is required!");
|
||||
}
|
||||
|
||||
const idOrError = UniqueID.create(customerId);
|
||||
if (idOrError.isFailure) {
|
||||
return this.invalidInputError("Invalid customer Id param!");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.useCase.execute({
|
||||
id: idOrError.object,
|
||||
data: request,
|
||||
});
|
||||
|
||||
if (result.isFailure) {
|
||||
const { error } = result;
|
||||
|
||||
switch (error.code) {
|
||||
case UseCaseError.NOT_FOUND_ERROR:
|
||||
return this.notFoundError("Customer not found", error);
|
||||
|
||||
case UseCaseError.INVALID_INPUT_DATA:
|
||||
return this.invalidInputError(error.message);
|
||||
|
||||
case UseCaseError.UNEXCEPTED_ERROR:
|
||||
return this.internalServerError(result.error.message, result.error);
|
||||
|
||||
case UseCaseError.REPOSITORY_ERROR:
|
||||
return this.conflictError(result.error, result.error.details);
|
||||
|
||||
default:
|
||||
return this.clientError(result.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const customer = <Customer>result.object;
|
||||
|
||||
return this.ok<IUpdateCustomer_Response_DTO>(this.presenter.map(customer, this.context));
|
||||
} catch (e: unknown) {
|
||||
return this.fail(e as IServerError);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
modules/customers/src/api/domain/aggregates/customer.ts
Normal file
130
modules/customers/src/api/domain/aggregates/customer.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import {
|
||||
AggregateRoot,
|
||||
EmailAddress,
|
||||
PhoneNumber,
|
||||
PostalAddress,
|
||||
TINNumber,
|
||||
UniqueID,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
export interface CustomerProps {
|
||||
reference: string;
|
||||
isFreelancer: boolean;
|
||||
name: string;
|
||||
tin: TINNumber;
|
||||
address: PostalAddress;
|
||||
email: EmailAddress;
|
||||
phone: PhoneNumber;
|
||||
legalRecord: string;
|
||||
defaultTax: number;
|
||||
status: string;
|
||||
langCode: string;
|
||||
currencyCode: string;
|
||||
|
||||
tradeName: Maybe<string>;
|
||||
website: Maybe<string>;
|
||||
fax: Maybe<PhoneNumber>;
|
||||
}
|
||||
|
||||
export interface ICustomer {
|
||||
id: UniqueID;
|
||||
reference: string;
|
||||
name: string;
|
||||
tin: TINNumber;
|
||||
address: PostalAddress;
|
||||
email: EmailAddress;
|
||||
phone: PhoneNumber;
|
||||
legalRecord: string;
|
||||
defaultTax: number;
|
||||
langCode: string;
|
||||
currencyCode: string;
|
||||
|
||||
tradeName: Maybe<string>;
|
||||
fax: Maybe<PhoneNumber>;
|
||||
website: Maybe<string>;
|
||||
|
||||
isCustomer: boolean;
|
||||
isFreelancer: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export class Customer extends AggregateRoot<CustomerProps> implements ICustomer {
|
||||
static create(props: CustomerProps, id?: UniqueID): Result<Customer, Error> {
|
||||
const contact = new Customer(props, id);
|
||||
|
||||
// Reglas de negocio / validaciones
|
||||
// ...
|
||||
// ...
|
||||
|
||||
// 🔹 Disparar evento de dominio "CustomerAuthenticatedEvent"
|
||||
//const { contact } = props;
|
||||
//user.addDomainEvent(new CustomerAuthenticatedEvent(id, contact.toString()));
|
||||
|
||||
return Result.ok(contact);
|
||||
}
|
||||
|
||||
get reference() {
|
||||
return this.props.reference;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.props.name;
|
||||
}
|
||||
|
||||
get tradeName() {
|
||||
return this.props.tradeName;
|
||||
}
|
||||
|
||||
get tin(): TINNumber {
|
||||
return this.props.tin;
|
||||
}
|
||||
|
||||
get address(): PostalAddress {
|
||||
return this.props.address;
|
||||
}
|
||||
|
||||
get email(): EmailAddress {
|
||||
return this.props.email;
|
||||
}
|
||||
|
||||
get phone(): PhoneNumber {
|
||||
return this.props.phone;
|
||||
}
|
||||
|
||||
get fax(): Maybe<PhoneNumber> {
|
||||
return this.props.fax;
|
||||
}
|
||||
|
||||
get website() {
|
||||
return this.props.website;
|
||||
}
|
||||
|
||||
get legalRecord() {
|
||||
return this.props.legalRecord;
|
||||
}
|
||||
|
||||
get defaultTax() {
|
||||
return this.props.defaultTax;
|
||||
}
|
||||
|
||||
get langCode() {
|
||||
return this.props.langCode;
|
||||
}
|
||||
|
||||
get currencyCode() {
|
||||
return this.props.currencyCode;
|
||||
}
|
||||
|
||||
get isCustomer(): boolean {
|
||||
return !this.props.isFreelancer;
|
||||
}
|
||||
|
||||
get isFreelancer(): boolean {
|
||||
return this.props.isFreelancer;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.props.status === "active";
|
||||
}
|
||||
}
|
||||
1
modules/customers/src/api/domain/aggregates/index.ts
Normal file
1
modules/customers/src/api/domain/aggregates/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer";
|
||||
4
modules/customers/src/api/domain/index.ts
Normal file
4
modules/customers/src/api/domain/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./aggregates";
|
||||
export * from "./repositories";
|
||||
export * from "./services";
|
||||
export * from "./value-objects";
|
||||
@ -0,0 +1,50 @@
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Collection, Result } from "@repo/rdx-utils";
|
||||
import { Customer } from "../aggregates";
|
||||
|
||||
export interface ICustomerRepository {
|
||||
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
|
||||
|
||||
/**
|
||||
*
|
||||
* Persiste una nueva factura o actualiza una existente.
|
||||
*
|
||||
* @param customer - El agregado a guardar.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error>
|
||||
*/
|
||||
save(customer: Customer, transaction: any): Promise<Result<Customer, Error>>;
|
||||
|
||||
/**
|
||||
*
|
||||
* Busca una factura por su identificador único.
|
||||
* @param id - UUID de la factura.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error>
|
||||
*/
|
||||
findById(id: UniqueID, transaction: any): Promise<Result<Customer, Error>>;
|
||||
|
||||
/**
|
||||
*
|
||||
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
|
||||
* @param criteria - Criterios de búsqueda.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer[], Error>
|
||||
*
|
||||
* @see Criteria
|
||||
*/
|
||||
findByCriteria(
|
||||
criteria: Criteria,
|
||||
transaction: any
|
||||
): Promise<Result<Collection<Customer>, Error>>;
|
||||
|
||||
/**
|
||||
*
|
||||
* Elimina o marca como eliminada una factura.
|
||||
* @param id - UUID de la factura a eliminar.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<void, Error>
|
||||
*/
|
||||
deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>>;
|
||||
}
|
||||
1
modules/customers/src/api/domain/repositories/index.ts
Normal file
1
modules/customers/src/api/domain/repositories/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer-repository.interface";
|
||||
@ -0,0 +1,33 @@
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Collection, Result } from "@repo/rdx-utils";
|
||||
import { Customer, CustomerProps } from "../aggregates";
|
||||
|
||||
export interface ICustomerService {
|
||||
build(props: CustomerProps, id?: UniqueID): Result<Customer, Error>;
|
||||
|
||||
save(invoice: Customer, transaction: any): Promise<Result<Customer, Error>>;
|
||||
|
||||
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
|
||||
|
||||
findByCriteria(
|
||||
criteria: Criteria,
|
||||
transaction?: any
|
||||
): Promise<Result<Collection<Customer>, Error>>;
|
||||
|
||||
getById(id: UniqueID, transaction?: any): Promise<Result<Customer>>;
|
||||
|
||||
updateById(
|
||||
id: UniqueID,
|
||||
data: Partial<CustomerProps>,
|
||||
transaction?: any
|
||||
): Promise<Result<Customer, Error>>;
|
||||
|
||||
createCustomer(
|
||||
id: UniqueID,
|
||||
data: CustomerProps,
|
||||
transaction?: any
|
||||
): Promise<Result<Customer, Error>>;
|
||||
|
||||
deleteById(id: UniqueID, transaction?: any): Promise<Result<void, Error>>;
|
||||
}
|
||||
149
modules/customers/src/api/domain/services/customer.service.ts
Normal file
149
modules/customers/src/api/domain/services/customer.service.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Collection, Result } from "@repo/rdx-utils";
|
||||
import { Transaction } from "sequelize";
|
||||
import { Customer, CustomerProps } from "../aggregates";
|
||||
import { ICustomerRepository } from "../repositories";
|
||||
import { ICustomerService } from "./customer-service.interface";
|
||||
|
||||
export class CustomerService implements ICustomerService {
|
||||
constructor(private readonly repository: ICustomerRepository) {}
|
||||
|
||||
/**
|
||||
* Construye un nuevo agregado Customer a partir de props validadas.
|
||||
*
|
||||
* @param props - Las propiedades ya validadas para crear la factura.
|
||||
* @param id - Identificador UUID de la factura (opcional).
|
||||
* @returns Result<Customer, Error> - El agregado construido o un error si falla la creación.
|
||||
*/
|
||||
build(props: CustomerProps, id?: UniqueID): Result<Customer, Error> {
|
||||
return Customer.create(props, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda una instancia de Customer en persistencia.
|
||||
*
|
||||
* @param invoice - El agregado a guardar.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error> - El agregado guardado o un error si falla la operación.
|
||||
*/
|
||||
async save(invoice: Customer, transaction: any): Promise<Result<Customer, Error>> {
|
||||
const saved = await this.repository.save(invoice, transaction);
|
||||
return saved.isSuccess ? Result.ok(invoice) : Result.fail(saved.error);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Comprueba si existe o no en persistencia una factura con el ID proporcionado
|
||||
*
|
||||
* @param id - Identificador UUID de la factura.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Boolean, Error> - Existe la factura o no.
|
||||
*/
|
||||
|
||||
async existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>> {
|
||||
return this.repository.existsById(id, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene una colección de facturas que cumplen con los filtros definidos en un objeto Criteria.
|
||||
*
|
||||
* @param criteria - Objeto con condiciones de filtro, paginación y orden.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Collection<Customer>, Error> - Colección de facturas o error.
|
||||
*/
|
||||
async findByCriteria(
|
||||
criteria: Criteria,
|
||||
transaction?: Transaction
|
||||
): Promise<Result<Collection<Customer>, Error>> {
|
||||
const customersOrError = await this.repository.findByCriteria(criteria, transaction);
|
||||
if (customersOrError.isFailure) {
|
||||
console.error(customersOrError.error);
|
||||
return Result.fail(customersOrError.error);
|
||||
}
|
||||
|
||||
// Solo devolver usuarios activos
|
||||
//const allCustomers = customersOrError.data.filter((customer) => customer.isActive);
|
||||
//return Result.ok(new Collection(allCustomers));
|
||||
|
||||
return customersOrError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera una factura por su identificador único.
|
||||
*
|
||||
* @param id - Identificador UUID de la factura.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error> - Factura encontrada o error.
|
||||
*/
|
||||
async getById(id: UniqueID, transaction?: Transaction): Promise<Result<Customer>> {
|
||||
return await this.repository.findById(id, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza parcialmente una factura existente con nuevos datos.
|
||||
*
|
||||
* @param id - Identificador de la factura a actualizar.
|
||||
* @param changes - Subconjunto de props válidas para aplicar.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error> - Factura actualizada o error.
|
||||
*/
|
||||
async updateById(
|
||||
customerId: UniqueID,
|
||||
changes: Partial<CustomerProps>,
|
||||
transaction?: Transaction
|
||||
): Promise<Result<Customer, Error>> {
|
||||
// Verificar si la factura existe
|
||||
const customerOrError = await this.repository.findById(customerId, transaction);
|
||||
if (customerOrError.isFailure) {
|
||||
return Result.fail(new Error("Customer not found"));
|
||||
}
|
||||
|
||||
return Result.fail(new Error("No implementado"));
|
||||
|
||||
/*const updatedCustomerOrError = Customer.update(customerOrError.data, data);
|
||||
if (updatedCustomerOrError.isFailure) {
|
||||
return Result.fail(
|
||||
new Error(`Error updating customer: ${updatedCustomerOrError.error.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
const updateCustomer = updatedCustomerOrError.data;
|
||||
|
||||
await this.repo.update(updateCustomer, transaction);
|
||||
return Result.ok(updateCustomer);*/
|
||||
}
|
||||
|
||||
async createCustomer(
|
||||
customerId: UniqueID,
|
||||
data: CustomerProps,
|
||||
transaction?: Transaction
|
||||
): Promise<Result<Customer, Error>> {
|
||||
// Verificar si la factura existe
|
||||
const customerOrError = await this.repository.findById(customerId, transaction);
|
||||
if (customerOrError.isSuccess) {
|
||||
return Result.fail(new Error("Customer exists"));
|
||||
}
|
||||
|
||||
const newCustomerOrError = Customer.create(data, customerId);
|
||||
if (newCustomerOrError.isFailure) {
|
||||
return Result.fail(new Error(`Error creating customer: ${newCustomerOrError.error.message}`));
|
||||
}
|
||||
|
||||
const newCustomer = newCustomerOrError.data;
|
||||
|
||||
await this.repository.create(newCustomer, transaction);
|
||||
return Result.ok(newCustomer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina (o marca como eliminada) una factura según su ID.
|
||||
*
|
||||
* @param id - Identificador UUID de la factura.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<boolean, Error> - Resultado de la operación.
|
||||
*/
|
||||
async deleteById(id: UniqueID, transaction?: Transaction): Promise<Result<void, Error>> {
|
||||
return this.repository.deleteById(id, transaction);
|
||||
}
|
||||
}
|
||||
2
modules/customers/src/api/domain/services/index.ts
Normal file
2
modules/customers/src/api/domain/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./customer-service.interface";
|
||||
export * from "./customer.service";
|
||||
@ -0,0 +1,38 @@
|
||||
import { ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
interface ICustomerAddressTypeProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum INVOICE_ADDRESS_TYPE {
|
||||
SHIPPING = "shipping",
|
||||
BILLING = "billing",
|
||||
}
|
||||
|
||||
export class CustomerAddressType extends ValueObject<ICustomerAddressTypeProps> {
|
||||
private static readonly ALLOWED_TYPES = ["shipping", "billing"];
|
||||
|
||||
static create(value: string): Result<CustomerAddressType, Error> {
|
||||
if (!this.ALLOWED_TYPES.includes(value)) {
|
||||
return Result.fail(
|
||||
new Error(
|
||||
`Invalid address type: ${value}. Allowed types are: ${this.ALLOWED_TYPES.join(", ")}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return Result.ok(new CustomerAddressType({ value }));
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.getValue();
|
||||
}
|
||||
|
||||
toPrimitive(): string {
|
||||
return this.getValue();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { DomainValidationError } from "@erp/core/api";
|
||||
import { ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
interface ICustomerNumberProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class CustomerNumber extends ValueObject<ICustomerNumberProps> {
|
||||
private static readonly MAX_LENGTH = 255;
|
||||
private static readonly FIELD = "invoiceNumber";
|
||||
private static readonly ERROR_CODE = "INVALID_INVOICE_NUMBER";
|
||||
|
||||
protected static validate(value: string) {
|
||||
const schema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(CustomerNumber.MAX_LENGTH, {
|
||||
message: `String must be at most ${CustomerNumber.MAX_LENGTH} characters long`,
|
||||
});
|
||||
return schema.safeParse(value);
|
||||
}
|
||||
|
||||
static create(value: string) {
|
||||
const result = CustomerNumber.validate(value);
|
||||
|
||||
if (!result.success) {
|
||||
const detail = result.error.message;
|
||||
return Result.fail(
|
||||
new DomainValidationError(CustomerNumber.ERROR_CODE, CustomerNumber.FIELD, detail)
|
||||
);
|
||||
}
|
||||
return Result.ok(new CustomerNumber({ value }));
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.getValue();
|
||||
}
|
||||
|
||||
toPrimitive() {
|
||||
return this.getValue();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import { DomainValidationError } from "@erp/core/api";
|
||||
import { ValueObject } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
interface ICustomerSerieProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class CustomerSerie extends ValueObject<ICustomerSerieProps> {
|
||||
private static readonly MAX_LENGTH = 255;
|
||||
private static readonly FIELD = "invoiceSeries";
|
||||
private static readonly ERROR_CODE = "INVALID_INVOICE_SERIE";
|
||||
|
||||
protected static validate(value: string) {
|
||||
const schema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(CustomerSerie.MAX_LENGTH, {
|
||||
message: `String must be at most ${CustomerSerie.MAX_LENGTH} characters long`,
|
||||
});
|
||||
return schema.safeParse(value);
|
||||
}
|
||||
|
||||
static create(value: string) {
|
||||
const result = CustomerSerie.validate(value);
|
||||
|
||||
if (!result.success) {
|
||||
const detail = result.error.message;
|
||||
return Result.fail(
|
||||
new DomainValidationError(CustomerSerie.ERROR_CODE, CustomerSerie.FIELD, detail)
|
||||
);
|
||||
}
|
||||
return Result.ok(new CustomerSerie({ value }));
|
||||
}
|
||||
|
||||
static createNullable(value?: string): Result<Maybe<CustomerSerie>, Error> {
|
||||
if (!value || value.trim() === "") {
|
||||
return Result.ok(Maybe.none<CustomerSerie>());
|
||||
}
|
||||
|
||||
return CustomerSerie.create(value).map((value) => Maybe.some(value));
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.getValue();
|
||||
}
|
||||
|
||||
toPrimitive() {
|
||||
return this.getValue();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
import { DomainValidationError } from "@erp/core/api";
|
||||
import { ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
interface ICustomerStatusProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum INVOICE_STATUS {
|
||||
DRAFT = "draft",
|
||||
EMITTED = "emitted",
|
||||
SENT = "sent",
|
||||
RECEIVED = "received",
|
||||
REJECTED = "rejected",
|
||||
}
|
||||
export class CustomerStatus extends ValueObject<ICustomerStatusProps> {
|
||||
private static readonly ALLOWED_STATUSES = ["draft", "emitted", "sent", "received", "rejected"];
|
||||
private static readonly FIELD = "invoiceStatus";
|
||||
private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS";
|
||||
|
||||
private static readonly TRANSITIONS: Record<string, string[]> = {
|
||||
draft: [INVOICE_STATUS.EMITTED],
|
||||
emitted: [INVOICE_STATUS.SENT, INVOICE_STATUS.REJECTED, INVOICE_STATUS.DRAFT],
|
||||
sent: [INVOICE_STATUS.RECEIVED, INVOICE_STATUS.REJECTED],
|
||||
received: [],
|
||||
rejected: [],
|
||||
};
|
||||
|
||||
static create(value: string): Result<CustomerStatus, Error> {
|
||||
if (!CustomerStatus.ALLOWED_STATUSES.includes(value)) {
|
||||
const detail = `Estado de la factura no válido: ${value}`;
|
||||
return Result.fail(
|
||||
new DomainValidationError(CustomerStatus.ERROR_CODE, CustomerStatus.FIELD, detail)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
value === "rejected"
|
||||
? CustomerStatus.createRejected()
|
||||
: value === "sent"
|
||||
? CustomerStatus.createSent()
|
||||
: value === "emitted"
|
||||
? CustomerStatus.createSent()
|
||||
: value === ""
|
||||
? CustomerStatus.createReceived()
|
||||
: CustomerStatus.createDraft()
|
||||
);
|
||||
}
|
||||
|
||||
public static createDraft(): CustomerStatus {
|
||||
return new CustomerStatus({ value: INVOICE_STATUS.DRAFT });
|
||||
}
|
||||
|
||||
public static createEmitted(): CustomerStatus {
|
||||
return new CustomerStatus({ value: INVOICE_STATUS.EMITTED });
|
||||
}
|
||||
|
||||
public static createSent(): CustomerStatus {
|
||||
return new CustomerStatus({ value: INVOICE_STATUS.SENT });
|
||||
}
|
||||
|
||||
public static createReceived(): CustomerStatus {
|
||||
return new CustomerStatus({ value: INVOICE_STATUS.RECEIVED });
|
||||
}
|
||||
|
||||
public static createRejected(): CustomerStatus {
|
||||
return new CustomerStatus({ value: INVOICE_STATUS.REJECTED });
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
toPrimitive() {
|
||||
return this.getValue();
|
||||
}
|
||||
|
||||
canTransitionTo(nextStatus: string): boolean {
|
||||
return CustomerStatus.TRANSITIONS[this.props.value].includes(nextStatus);
|
||||
}
|
||||
|
||||
transitionTo(nextStatus: string): Result<CustomerStatus, Error> {
|
||||
if (!this.canTransitionTo(nextStatus)) {
|
||||
return Result.fail(
|
||||
new Error(`Transición no permitida de ${this.props.value} a ${nextStatus}`)
|
||||
);
|
||||
}
|
||||
return CustomerStatus.create(nextStatus);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.getValue();
|
||||
}
|
||||
}
|
||||
4
modules/customers/src/api/domain/value-objects/index.ts
Normal file
4
modules/customers/src/api/domain/value-objects/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./customer-address-type";
|
||||
export * from "./customer-number";
|
||||
export * from "./customer-serie";
|
||||
export * from "./customer-status";
|
||||
@ -1,5 +1,6 @@
|
||||
import { IModuleServer, ModuleParams } from "@erp/core/api";
|
||||
//import { customerInvoicesRouter, models } from "./infrastructure";
|
||||
import { customersRouter, models } from "./infrastructure";
|
||||
//import { customersRouter, models } from "./infrastructure";
|
||||
|
||||
export const customersAPIModule: IModuleServer = {
|
||||
name: "customers",
|
||||
@ -9,7 +10,7 @@ export const customersAPIModule: IModuleServer = {
|
||||
init(params: ModuleParams) {
|
||||
// const contacts = getService<ContactsService>("contacts");
|
||||
const { logger } = params;
|
||||
//customerInvoicesRouter(params);
|
||||
customersRouter(params);
|
||||
logger.info("🚀 Customers module initialized", { label: "customers" });
|
||||
},
|
||||
registerDependencies(params) {
|
||||
@ -18,7 +19,7 @@ export const customersAPIModule: IModuleServer = {
|
||||
label: "customers",
|
||||
});
|
||||
return {
|
||||
//models,
|
||||
models,
|
||||
services: {
|
||||
/*...*/
|
||||
},
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import { ILogger, ModuleParams, validateRequest } from "@erp/core/api";
|
||||
import { Application, NextFunction, Request, Response, Router } from "express";
|
||||
import { Sequelize } from "sequelize";
|
||||
import {
|
||||
CreateCustomerCommandSchema,
|
||||
CustomerListCriteriaSchema,
|
||||
DeleteCustomerByIdQuerySchema,
|
||||
GetCustomerByIdQuerySchema,
|
||||
} from "../../../common/dto";
|
||||
import {
|
||||
buildCreateCustomersController,
|
||||
buildDeleteCustomerController,
|
||||
buildGetCustomerController,
|
||||
buildListCustomersController,
|
||||
} from "../../controllers";
|
||||
|
||||
export const customersRouter = (params: ModuleParams) => {
|
||||
const { app, database, baseRoutePath, logger } = params as {
|
||||
app: Application;
|
||||
database: Sequelize;
|
||||
baseRoutePath: string;
|
||||
logger: ILogger;
|
||||
};
|
||||
|
||||
const routes: Router = Router({ mergeParams: true });
|
||||
|
||||
routes.get(
|
||||
"/",
|
||||
//checkTabContext,
|
||||
//checkUser,
|
||||
validateRequest(CustomerListCriteriaSchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
buildListCustomersController(database).execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
routes.get(
|
||||
"/:id",
|
||||
//checkTabContext,
|
||||
//checkUser,
|
||||
validateRequest(GetCustomerByIdQuerySchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
buildGetCustomerController(database).execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
routes.post(
|
||||
"/",
|
||||
//checkTabContext,
|
||||
//checkUser,
|
||||
validateRequest(CreateCustomerCommandSchema),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
buildCreateCustomersController(database).execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
/*routes.put(
|
||||
"/:customerId",
|
||||
validateAndParseBody(IUpdateCustomerRequestSchema),
|
||||
checkTabContext,
|
||||
//checkUser,
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
buildUpdateCustomerController().execute(req, res, next);
|
||||
}
|
||||
);*/
|
||||
|
||||
routes.delete(
|
||||
"/:id",
|
||||
//checkTabContext,
|
||||
//checkUser,
|
||||
validateRequest(DeleteCustomerByIdQuerySchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
buildDeleteCustomerController(database).execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
app.use(`${baseRoutePath}/customers`, routes);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./customers.routes";
|
||||
3
modules/customers/src/api/infrastructure/index.ts
Normal file
3
modules/customers/src/api/infrastructure/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./express";
|
||||
export * from "./mappers";
|
||||
export * from "./sequelize";
|
||||
@ -0,0 +1,128 @@
|
||||
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { InferCreationAttributes } from "sequelize";
|
||||
import {
|
||||
Customer,
|
||||
CustomerItem,
|
||||
CustomerItemDescription,
|
||||
CustomerItemDiscount,
|
||||
CustomerItemQuantity,
|
||||
CustomerItemUnitPrice,
|
||||
} from "../../domain";
|
||||
import { CustomerItemCreationAttributes, CustomerItemModel, CustomerModel } from "../sequelize";
|
||||
|
||||
export interface ICustomerItemMapper
|
||||
extends ISequelizeMapper<CustomerItemModel, CustomerItemCreationAttributes, CustomerItem> {}
|
||||
|
||||
export class CustomerItemMapper
|
||||
extends SequelizeMapper<CustomerItemModel, CustomerItemCreationAttributes, CustomerItem>
|
||||
implements ICustomerItemMapper
|
||||
{
|
||||
public mapToDomain(
|
||||
source: CustomerItemModel,
|
||||
params?: MapperParamsType
|
||||
): Result<CustomerItem, Error> {
|
||||
const { sourceParent } = params as { sourceParent: CustomerModel };
|
||||
|
||||
// Validación y creación de ID único
|
||||
const idOrError = UniqueID.create(source.item_id);
|
||||
if (idOrError.isFailure) {
|
||||
return Result.fail(idOrError.error);
|
||||
}
|
||||
|
||||
// Validación y creación de descripción
|
||||
const descriptionOrError = CustomerItemDescription.create(source.description || "");
|
||||
if (descriptionOrError.isFailure) {
|
||||
return Result.fail(descriptionOrError.error);
|
||||
}
|
||||
|
||||
// Validación y creación de cantidad
|
||||
const quantityOrError = CustomerItemQuantity.create({
|
||||
amount: source.quantity_amount,
|
||||
scale: source.quantity_scale,
|
||||
});
|
||||
if (quantityOrError.isFailure) {
|
||||
return Result.fail(quantityOrError.error);
|
||||
}
|
||||
|
||||
// Validación y creación de precio unitario
|
||||
const unitPriceOrError = CustomerItemUnitPrice.create({
|
||||
amount: source.unit_price_amount,
|
||||
scale: source.unit_price_scale,
|
||||
currency_code: sourceParent.invoice_currency,
|
||||
});
|
||||
if (unitPriceOrError.isFailure) {
|
||||
return Result.fail(unitPriceOrError.error);
|
||||
}
|
||||
|
||||
// Validación y creación de descuento
|
||||
const discountOrError = CustomerItemDiscount.create({
|
||||
amount: source.discount_amount || 0,
|
||||
scale: source.discount_scale || 0,
|
||||
});
|
||||
if (discountOrError.isFailure) {
|
||||
return Result.fail(discountOrError.error);
|
||||
}
|
||||
|
||||
// Combinación de resultados
|
||||
const result = Result.combine([
|
||||
idOrError,
|
||||
descriptionOrError,
|
||||
quantityOrError,
|
||||
unitPriceOrError,
|
||||
discountOrError,
|
||||
]);
|
||||
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
// Creación del objeto de dominio
|
||||
return CustomerItem.create(
|
||||
{
|
||||
description: descriptionOrError.data,
|
||||
quantity: quantityOrError.data,
|
||||
unitPrice: unitPriceOrError.data,
|
||||
discount: discountOrError.data,
|
||||
},
|
||||
idOrError.data
|
||||
);
|
||||
}
|
||||
|
||||
public mapToPersistence(
|
||||
source: CustomerItem,
|
||||
params?: MapperParamsType
|
||||
): InferCreationAttributes<CustomerItemModel, {}> {
|
||||
const { index, sourceParent } = params as {
|
||||
index: number;
|
||||
sourceParent: Customer;
|
||||
};
|
||||
|
||||
const lineData = {
|
||||
parent_id: undefined,
|
||||
invoice_id: sourceParent.id.toPrimitive(),
|
||||
item_type: "simple",
|
||||
position: index,
|
||||
|
||||
item_id: source.id.toPrimitive(),
|
||||
description: source.description.toPrimitive(),
|
||||
|
||||
quantity_amount: source.quantity.toPrimitive().amount,
|
||||
quantity_scale: source.quantity.toPrimitive().scale,
|
||||
|
||||
unit_price_amount: source.unitPrice.toPrimitive().amount,
|
||||
unit_price_scale: source.unitPrice.toPrimitive().scale,
|
||||
|
||||
subtotal_amount: source.subtotalPrice.toPrimitive().amount,
|
||||
subtotal_scale: source.subtotalPrice.toPrimitive().scale,
|
||||
|
||||
discount_amount: source.discount.toPrimitive().amount,
|
||||
discount_scale: source.discount.toPrimitive().scale,
|
||||
|
||||
total_amount: source.totalPrice.toPrimitive().amount,
|
||||
total_scale: source.totalPrice.toPrimitive().scale,
|
||||
};
|
||||
return lineData;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api";
|
||||
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { Customer, CustomerNumber, CustomerSerie, CustomerStatus } from "../../domain";
|
||||
import { CustomerCreationAttributes, CustomerModel } from "../sequelize";
|
||||
import { CustomerItemMapper } from "./customer-item.mapper";
|
||||
|
||||
export interface ICustomerMapper
|
||||
extends ISequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer> {}
|
||||
|
||||
export class CustomerMapper
|
||||
extends SequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer>
|
||||
implements ICustomerMapper
|
||||
{
|
||||
private customerItemMapper: CustomerItemMapper;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.customerItemMapper = new CustomerItemMapper(); // Instanciar el mapper de items
|
||||
}
|
||||
|
||||
public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result<Customer, Error> {
|
||||
const idOrError = UniqueID.create(source.id);
|
||||
const statusOrError = CustomerStatus.create(source.invoice_status);
|
||||
const customerSeriesOrError = CustomerSerie.create(source.invoice_series);
|
||||
const customerNumberOrError = CustomerNumber.create(source.invoice_number);
|
||||
const issueDateOrError = UtcDate.createFromISO(source.issue_date);
|
||||
const operationDateOrError = UtcDate.createFromISO(source.operation_date);
|
||||
|
||||
const result = Result.combine([
|
||||
idOrError,
|
||||
statusOrError,
|
||||
customerSeriesOrError,
|
||||
customerNumberOrError,
|
||||
issueDateOrError,
|
||||
operationDateOrError,
|
||||
]);
|
||||
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
// Mapear los items de la factura
|
||||
const itemsOrErrors = this.customerItemMapper.mapArrayToDomain(source.items, {
|
||||
sourceParent: source,
|
||||
...params,
|
||||
});
|
||||
|
||||
if (itemsOrErrors.isFailure) {
|
||||
return Result.fail(itemsOrErrors.error);
|
||||
}
|
||||
|
||||
const customerCurrency = source.invoice_currency || "EUR";
|
||||
|
||||
return Customer.create(
|
||||
{
|
||||
status: statusOrError.data,
|
||||
invoiceSeries: customerSeriesOrError.data,
|
||||
invoiceNumber: customerNumberOrError.data,
|
||||
issueDate: issueDateOrError.data,
|
||||
operationDate: operationDateOrError.data,
|
||||
currency: customerCurrency,
|
||||
items: itemsOrErrors.data,
|
||||
},
|
||||
idOrError.data
|
||||
);
|
||||
}
|
||||
|
||||
public mapToPersistence(source: Customer, params?: MapperParamsType): CustomerCreationAttributes {
|
||||
const subtotal = source.calculateSubtotal();
|
||||
const total = source.calculateTotal();
|
||||
|
||||
const items = this.customerItemMapper.mapCollectionToPersistence(source.items, params);
|
||||
|
||||
return {
|
||||
id: source.id.toString(),
|
||||
invoice_status: source.status.toPrimitive(),
|
||||
invoice_series: source.invoiceSeries.toPrimitive(),
|
||||
invoice_number: source.invoiceNumber.toPrimitive(),
|
||||
issue_date: source.issueDate.toPrimitive(),
|
||||
operation_date: source.operationDate.toPrimitive(),
|
||||
invoice_language: "es",
|
||||
invoice_currency: source.currency || "EUR",
|
||||
|
||||
subtotal_amount: subtotal.amount,
|
||||
subtotal_scale: subtotal.scale,
|
||||
|
||||
total_amount: total.amount,
|
||||
total_scale: total.scale,
|
||||
|
||||
items,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const customerMapper: CustomerMapper = new CustomerMapper();
|
||||
export { customerMapper };
|
||||
@ -0,0 +1 @@
|
||||
export * from "./customer.mapper";
|
||||
@ -0,0 +1,175 @@
|
||||
import {
|
||||
CreationOptional,
|
||||
DataTypes,
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
Model,
|
||||
Sequelize,
|
||||
} from "sequelize";
|
||||
|
||||
export type CustomerCreationAttributes = InferCreationAttributes<CustomerModel, {}> & {};
|
||||
|
||||
export class CustomerModel extends Model<
|
||||
InferAttributes<CustomerModel>,
|
||||
InferCreationAttributes<CustomerModel>
|
||||
> {
|
||||
// To avoid table creation
|
||||
/*static async sync(): Promise<any> {
|
||||
return Promise.resolve();
|
||||
}*/
|
||||
|
||||
declare id: string;
|
||||
declare reference: CreationOptional<string>;
|
||||
|
||||
declare is_freelancer: boolean;
|
||||
declare name: string;
|
||||
declare trade_name: CreationOptional<string>;
|
||||
declare tin: string;
|
||||
|
||||
declare street: string;
|
||||
declare city: string;
|
||||
declare state: string;
|
||||
declare postal_code: string;
|
||||
declare country: string;
|
||||
|
||||
declare email: string;
|
||||
declare phone: string;
|
||||
declare fax: CreationOptional<string>;
|
||||
declare website: CreationOptional<string>;
|
||||
|
||||
declare legal_record: string;
|
||||
|
||||
declare default_tax: number;
|
||||
declare status: string;
|
||||
declare lang_code: string;
|
||||
declare currency_code: string;
|
||||
}
|
||||
|
||||
export default (sequelize: Sequelize) => {
|
||||
CustomerModel.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
},
|
||||
reference: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
is_freelancer: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
trade_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
tin: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
|
||||
street: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
city: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
state: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
postal_code: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
country: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
validate: {
|
||||
isEmail: true,
|
||||
},
|
||||
},
|
||||
phone: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
fax: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
website: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
validate: {
|
||||
isUrl: true,
|
||||
},
|
||||
},
|
||||
legal_record: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
|
||||
default_tax: {
|
||||
type: new DataTypes.SMALLINT(),
|
||||
allowNull: false,
|
||||
defaultValue: 2100,
|
||||
},
|
||||
|
||||
lang_code: {
|
||||
type: DataTypes.STRING(2),
|
||||
allowNull: false,
|
||||
defaultValue: "es",
|
||||
},
|
||||
|
||||
currency_code: {
|
||||
type: new DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: "EUR",
|
||||
},
|
||||
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: "active",
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: "customers",
|
||||
|
||||
paranoid: true, // softs deletes
|
||||
timestamps: true,
|
||||
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
deletedAt: "deleted_at",
|
||||
|
||||
indexes: [
|
||||
{ name: "email_idx", fields: ["email"], unique: true },
|
||||
{ name: "reference_idx", fields: ["reference"], unique: true },
|
||||
],
|
||||
|
||||
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
|
||||
|
||||
defaultScope: {},
|
||||
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
return CustomerModel;
|
||||
};
|
||||
@ -0,0 +1,121 @@
|
||||
import { SequelizeRepository, errorMapper } from "@erp/core/api";
|
||||
import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Collection, Result } from "@repo/rdx-utils";
|
||||
import { Sequelize, Transaction } from "sequelize";
|
||||
import { Customer, ICustomerRepository } from "../../domain";
|
||||
import { ICustomerMapper } from "../mappers/customer.mapper";
|
||||
import { CustomerModel } from "./customer.model";
|
||||
|
||||
export class CustomerRepository
|
||||
extends SequelizeRepository<Customer>
|
||||
implements ICustomerRepository
|
||||
{
|
||||
//private readonly model: typeof CustomerModel;
|
||||
private readonly mapper!: ICustomerMapper;
|
||||
|
||||
constructor(database: Sequelize, mapper: ICustomerMapper) {
|
||||
super(database);
|
||||
|
||||
//Customer = database.model("Customer") as typeof CustomerModel;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
async existsById(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
|
||||
try {
|
||||
const result = await this._exists(CustomerModel, "id", id.toString(), transaction);
|
||||
|
||||
return Result.ok(Boolean(result));
|
||||
} catch (err: unknown) {
|
||||
return Result.fail(errorMapper.toDomainError(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Persiste una nueva factura o actualiza una existente.
|
||||
*
|
||||
* @param invoice - El agregado a guardar.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error>
|
||||
*/
|
||||
async save(invoice: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
|
||||
try {
|
||||
const data = this.mapper.mapToPersistence(invoice);
|
||||
await CustomerModel.upsert(data, { transaction });
|
||||
return Result.ok(invoice);
|
||||
} catch (err: unknown) {
|
||||
return Result.fail(errorMapper.toDomainError(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Busca una factura por su identificador único.
|
||||
* @param id - UUID de la factura.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error>
|
||||
*/
|
||||
async findById(id: UniqueID, transaction: Transaction): Promise<Result<Customer, Error>> {
|
||||
try {
|
||||
const rawData = await this._findById(CustomerModel, id.toString(), { transaction });
|
||||
|
||||
if (!rawData) {
|
||||
return Result.fail(new Error(`Invoice with id ${id} not found.`));
|
||||
}
|
||||
|
||||
return this.mapper.mapToDomain(rawData);
|
||||
} catch (err: unknown) {
|
||||
return Result.fail(errorMapper.toDomainError(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
|
||||
* @param criteria - Criterios de búsqueda.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer[], Error>
|
||||
*
|
||||
* @see Criteria
|
||||
*/
|
||||
public async findByCriteria(
|
||||
criteria: Criteria,
|
||||
transaction: Transaction
|
||||
): Promise<Result<Collection<Customer>, Error>> {
|
||||
try {
|
||||
const converter = new CriteriaToSequelizeConverter();
|
||||
const query = converter.convert(criteria);
|
||||
|
||||
console.debug({ criteria, transaction, query, CustomerModel });
|
||||
|
||||
const instances = await CustomerModel.findAll({
|
||||
...query,
|
||||
transaction,
|
||||
});
|
||||
|
||||
console.debug(instances);
|
||||
|
||||
return this.mapper.mapArrayToDomain(instances);
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
return Result.fail(errorMapper.toDomainError(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Elimina o marca como eliminada una factura.
|
||||
* @param id - UUID de la factura a eliminar.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<void, Error>
|
||||
*/
|
||||
async deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>> {
|
||||
try {
|
||||
await this._deleteById(CustomerModel, id, false, transaction);
|
||||
return Result.ok<void>();
|
||||
} catch (err: unknown) {
|
||||
return Result.fail(errorMapper.toDomainError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import customerModelInit from "./customer.model";
|
||||
|
||||
export * from "./customer.model";
|
||||
export * from "./customer.repository";
|
||||
|
||||
// Array de inicializadores para que registerModels() lo use
|
||||
export const models = [customerModelInit];
|
||||
@ -0,0 +1,36 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const CreateCustomerCommandSchema = z.object({
|
||||
id: z.uuid(),
|
||||
invoice_status: z.string(),
|
||||
invoice_number: z.string().min(1, "Customer invoice number is required"),
|
||||
invoice_series: z.string().min(1, "Customer invoice series is required"),
|
||||
issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }),
|
||||
operation_date: z.string().datetime({ offset: true, message: "Invalid operation date format" }),
|
||||
description: z.string(),
|
||||
language_code: z.string().min(2, "Language code must be at least 2 characters long"),
|
||||
currency_code: z.string().min(3, "Currency code must be at least 3 characters long"),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
description: z.string().min(1, "Item description is required"),
|
||||
quantity: z.object({
|
||||
amount: z.number().positive("Quantity amount must be positive"),
|
||||
scale: z.number().int().nonnegative("Quantity scale must be a non-negative integer"),
|
||||
}),
|
||||
unit_price: z.object({
|
||||
amount: z.number().positive("Unit price amount must be positive"),
|
||||
scale: z.number().int().nonnegative("Unit price scale must be a non-negative integer"),
|
||||
currency_code: z
|
||||
.string()
|
||||
.min(3, "Unit price currency code must be at least 3 characters long"),
|
||||
}),
|
||||
discount: z.object({
|
||||
amount: z.number().nonnegative("Discount amount cannot be negative"),
|
||||
scale: z.number().int().nonnegative("Discount scale must be a non-negative integer"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type CreateCustomerCommandDTO = z.infer<typeof CreateCustomerCommandSchema>;
|
||||
@ -0,0 +1,33 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
/**
|
||||
* DTO que transporta los parámetros de la consulta (paginación, filtros, etc.)
|
||||
* para la búsqueda de facturas de cliente.
|
||||
*
|
||||
* Este DTO es utilizado por el endpoint:
|
||||
* `GET /customers` (listado / búsqueda de facturas).
|
||||
*
|
||||
*/
|
||||
|
||||
export const CustomerListCriteriaSchema = z.object({
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(25),
|
||||
fromDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || !Number.isNaN(Date.parse(val)), {
|
||||
message: "Invalid date format for fromDate",
|
||||
}),
|
||||
toDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || !Number.isNaN(Date.parse(val)), {
|
||||
message: "Invalid date format for toDate",
|
||||
}),
|
||||
status: z.enum(["DRAFT", "POSTED", "PAID", "CANCELLED"]).default("DRAFT"),
|
||||
customerId: z.string().optional(),
|
||||
sortBy: z.enum(["issueDate", "totalAmount", "number"]).default("issueDate"),
|
||||
sortDir: z.enum(["ASC", "DESC"]).default("DESC"),
|
||||
});
|
||||
|
||||
export type ListCustomersQueryDTO = z.infer<typeof CustomerListCriteriaSchema>;
|
||||
@ -0,0 +1,13 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
/**
|
||||
* Este DTO es utilizado por el endpoint:
|
||||
* `DELETE /customers/:id` (eliminar una factura por ID).
|
||||
*
|
||||
*/
|
||||
|
||||
export const DeleteCustomerByIdParamsSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type DeleteCustomerByIdParamsDTO = z.infer<typeof DeleteCustomerByIdParamsSchema>;
|
||||
@ -0,0 +1,13 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
/**
|
||||
* Este DTO es utilizado por el endpoint:
|
||||
* `GET /customers/:id` (consultar una factura por ID).
|
||||
*
|
||||
*/
|
||||
|
||||
export const GetCustomerByIdParamsSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type GetCustomerByIdParamsDTO = z.infer<typeof GetCustomerByIdParamsSchema>;
|
||||
@ -1,39 +0,0 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
/**
|
||||
* DTO que transporta los parámetros de la consulta (paginación, filtros, etc.)
|
||||
* para la búsqueda de clientes.
|
||||
*
|
||||
* Este DTO es utilizado por el endpoint:
|
||||
* `GET /customers` (listado / búsqueda de clientes).
|
||||
*
|
||||
*/
|
||||
|
||||
const operatorEnum = z.enum([
|
||||
"CONTAINS",
|
||||
"NOT_CONTAINS",
|
||||
"NOT_EQUALS",
|
||||
"GREATER_THAN",
|
||||
"GREATER_THAN_OR_EQUAL",
|
||||
"LOWER_THAN",
|
||||
"LOWER_THAN_OR_EQUAL",
|
||||
"EQUALS",
|
||||
]);
|
||||
|
||||
const filterSchema = z.object({
|
||||
field: z.string(),
|
||||
operator: operatorEnum,
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const ListCustomersQuerySchema = z.object({
|
||||
filters: z.array(filterSchema).optional(),
|
||||
|
||||
pageSize: z.coerce.number().int().positive().optional(),
|
||||
pageNumber: z.coerce.number().int().nonnegative().optional(),
|
||||
|
||||
orderBy: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).default("asc").optional(),
|
||||
});
|
||||
|
||||
export type ListCustomersQueryDTO = z.infer<typeof ListCustomersQuerySchema>;
|
||||
@ -0,0 +1,17 @@
|
||||
import { MetadataSchema } from "@erp/core";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const CustomerCreatedResponseSchema = z.object({
|
||||
id: z.uuid(),
|
||||
invoice_status: z.string(),
|
||||
invoice_number: z.string(),
|
||||
invoice_series: z.string(),
|
||||
issue_date: z.iso.datetime({ offset: true }),
|
||||
operation_date: z.iso.datetime({ offset: true }),
|
||||
language_code: z.string(),
|
||||
currency: z.string(),
|
||||
|
||||
metadata: MetadataSchema.optional(),
|
||||
});
|
||||
|
||||
export type CustomerCreatedResponseDTO = z.infer<typeof CustomerCreatedResponseSchema>;
|
||||
@ -1,14 +1,14 @@
|
||||
import { MetadataSchema, createListViewSchema } from "@erp/core";
|
||||
import { MetadataSchema, createListViewResultSchema } from "@erp/core";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const ListCustomersResultSchema = createListViewSchema(
|
||||
export const CustomerListResponseSchema = createListViewResultSchema(
|
||||
z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string().optional(),
|
||||
id: z.string(),
|
||||
reference: z.string(),
|
||||
|
||||
is_freelancer: z.boolean(),
|
||||
name: z.string(),
|
||||
trade_name: z.string().optional(),
|
||||
trade_name: z.string(),
|
||||
tin: z.string(),
|
||||
|
||||
street: z.string(),
|
||||
@ -17,8 +17,12 @@ export const ListCustomersResultSchema = createListViewSchema(
|
||||
postal_code: z.string(),
|
||||
country: z.string(),
|
||||
|
||||
email: z.email(),
|
||||
email: z.string(),
|
||||
phone: z.string(),
|
||||
fax: z.string(),
|
||||
website: z.string(),
|
||||
|
||||
legal_record: z.string(),
|
||||
|
||||
default_tax: z.number(),
|
||||
status: z.string(),
|
||||
@ -29,4 +33,4 @@ export const ListCustomersResultSchema = createListViewSchema(
|
||||
})
|
||||
);
|
||||
|
||||
export type ListCustomersResultDTO = z.infer<typeof ListCustomersResultSchema>;
|
||||
export type CustomerListResponsetDTO = z.infer<typeof CustomerListResponseSchema>;
|
||||
@ -0,0 +1,17 @@
|
||||
import { MetadataSchema } from "@erp/core";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const GetCustomerByIdResultSchema = z.object({
|
||||
id: z.uuid(),
|
||||
invoice_status: z.string(),
|
||||
invoice_number: z.string(),
|
||||
invoice_series: z.string(),
|
||||
issue_date: z.iso.datetime({ offset: true }),
|
||||
operation_date: z.iso.datetime({ offset: true }),
|
||||
language_code: z.string(),
|
||||
currency: z.string(),
|
||||
|
||||
metadata: MetadataSchema.optional(),
|
||||
});
|
||||
|
||||
export type GetCustomerByIdResultDTO = z.infer<typeof GetCustomerByIdResultSchema>;
|
||||
@ -1 +1 @@
|
||||
export * from "./list-customers.result.dto";
|
||||
export * from "./customer-list.response.dto";
|
||||
|
||||
@ -3,7 +3,7 @@ import DataTable, { TableColumn } from "react-data-table-component";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { buildTextFilters } from "@erp/core/client";
|
||||
import { ListCustomersResultDTO } from "@erp/customer-invoices/common/dto";
|
||||
import { ListCustomersResultDTO } from "@erp/customers/common/dto";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@ -137,11 +137,16 @@ export const ClientSelector = () => {
|
||||
pageNumber,
|
||||
});
|
||||
|
||||
const handleSelectClient = (event): void => {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4 max-w-2xl'>
|
||||
<div className='space-y-1'>
|
||||
<Label>Cliente</Label>
|
||||
<Button variant='outline' className='w-full justify-start' onClick={() => setOpen(true)}>
|
||||
<Button variant='outline' className='w-full justify-start' onClick={handleSelectClient}>
|
||||
<User className='h-4 w-4 mr-2' />
|
||||
{selectedCustomer ? selectedCustomer.name : "Seleccionar cliente"}
|
||||
</Button>
|
||||
@ -202,9 +207,9 @@ export const ClientSelector = () => {
|
||||
}}
|
||||
pagination
|
||||
paginationServer
|
||||
paginationPerPage={perPage}
|
||||
paginationTotalRows={data?.total ?? 0}
|
||||
onChangePage={(p) => setPage(p)}
|
||||
paginationPerPage={pageSize}
|
||||
paginationTotalRows={data?.total_items ?? 0}
|
||||
onChangePage={(p) => setPageNumber(p)}
|
||||
highlightOnHover
|
||||
pointerOnHover
|
||||
noDataComponent='No se encontraron resultados'
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Separator,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { formatCurrency } from "../pages/create/utils";
|
||||
|
||||
export const CustomerPricesCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { register, formState, control, watch } = useFormContext();
|
||||
|
||||
/*const pricesWatch = useWatch({ control, name: ["subtotal_price", "discount", "tax"] });
|
||||
|
||||
const totals = calculateQuoteTotals(pricesWatch);
|
||||
|
||||
const subtotal_price = formatNumber(totals.subtotalPrice);
|
||||
const discount_price = formatNumber(totals.discountPrice);
|
||||
const tax_price = formatNumber(totals.taxesPrice);
|
||||
const total_price = formatNumber(totals.totalPrice);*/
|
||||
|
||||
const currency_symbol = watch("currency");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Impuestos y Totales</CardTitle>
|
||||
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex flex-row items-end gap-2 p-4'>
|
||||
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
|
||||
<div className='grid gap-1 font-semibold text-right text-muted-foreground'>
|
||||
<CardDescription className='text-sm'>
|
||||
{t("form_fields.subtotal_price.label")}
|
||||
</CardDescription>
|
||||
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
|
||||
{formatCurrency(watch("subtotal_price.amount"), 2, watch("currency"))}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='w-px h-16 mx-2' />
|
||||
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
|
||||
<div className='grid gap-1 font-medium text-muted-foreground'>
|
||||
<CardDescription className='text-sm'>{t("form_fields.discount.label")}</CardDescription>
|
||||
</div>
|
||||
<div className='grid gap-1 font-semibold text-muted-foreground'>
|
||||
<CardDescription className='text-sm text-right'>
|
||||
{t("form_fields.discount_price.label")}
|
||||
</CardDescription>
|
||||
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
|
||||
{"-"} {formatCurrency(watch("discount_price.amount"), 2, watch("currency"))}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation='vertical' className='w-px h-16 mx-2' />
|
||||
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
|
||||
<div className='grid gap-1 font-medium text-muted-foreground'>
|
||||
<CardDescription className='text-sm'>{t("form_fields.tax.label")}</CardDescription>
|
||||
</div>
|
||||
<div className='grid gap-1 font-semibold text-muted-foreground'>
|
||||
<CardDescription className='text-sm text-right'>
|
||||
{t("form_fields.tax_price.label")}
|
||||
</CardDescription>
|
||||
<CardTitle className='flex items-baseline justify-end gap-1 text-2xl tabular-nums'>
|
||||
{formatCurrency(watch("tax_price.amount"), 2, watch("currency"))}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>{" "}
|
||||
<Separator orientation='vertical' className='w-px h-16 mx-2' />
|
||||
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
|
||||
<div className='grid gap-0'>
|
||||
<CardDescription className='text-sm font-semibold text-right text-foreground'>
|
||||
{t("form_fields.total_price.label")}
|
||||
</CardDescription>
|
||||
<CardTitle className='flex items-baseline justify-end gap-1 text-3xl tabular-nums'>
|
||||
{formatCurrency(watch("total_price.amount"), 2, watch("currency"))}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,65 @@
|
||||
import { Badge } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { forwardRef } from "react";
|
||||
import { useTranslation } from "../i18n";
|
||||
|
||||
export type CustomerStatus = "draft" | "emitted" | "sent" | "received" | "rejected";
|
||||
|
||||
export type CustomerStatusBadgeProps = {
|
||||
status: string; // permitir cualquier valor
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const statusColorConfig: Record<CustomerStatus, { badge: string; dot: string }> = {
|
||||
draft: {
|
||||
badge:
|
||||
"bg-gray-600/10 dark:bg-gray-600/20 hover:bg-gray-600/10 text-gray-500 border-gray-600/60",
|
||||
dot: "bg-gray-500",
|
||||
},
|
||||
emitted: {
|
||||
badge:
|
||||
"bg-amber-600/10 dark:bg-amber-600/20 hover:bg-amber-600/10 text-amber-500 border-amber-600/60",
|
||||
dot: "bg-amber-500",
|
||||
},
|
||||
sent: {
|
||||
badge:
|
||||
"bg-cyan-600/10 dark:bg-cyan-600/20 hover:bg-cyan-600/10 text-cyan-500 border-cyan-600/60 shadow-none rounded-full",
|
||||
dot: "bg-cyan-500",
|
||||
},
|
||||
received: {
|
||||
badge:
|
||||
"bg-emerald-600/10 dark:bg-emerald-600/20 hover:bg-emerald-600/10 text-emerald-500 border-emerald-600/60",
|
||||
dot: "bg-emerald-500",
|
||||
},
|
||||
rejected: {
|
||||
badge: "bg-red-600/10 dark:bg-red-600/20 hover:bg-red-600/10 text-red-500 border-red-600/60",
|
||||
dot: "bg-red-500",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomerStatusBadge = forwardRef<HTMLDivElement, CustomerStatusBadgeProps>(
|
||||
({ status, className, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const normalizedStatus = status.toLowerCase() as CustomerStatus;
|
||||
const config = statusColorConfig[normalizedStatus];
|
||||
const commonClassName =
|
||||
"transition-colors duration-200 cursor-pointer shadow-none rounded-full";
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<Badge ref={ref} className={cn(commonClassName, className)} {...props}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className={cn(commonClassName, config.badge, className)} {...props}>
|
||||
<div className={cn("h-1.5 w-1.5 rounded-full mr-2", config.dot)} />
|
||||
{t(`status.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CustomerStatusBadge.displayName = "CustomerStatusBadge";
|
||||
@ -0,0 +1,73 @@
|
||||
import { MultiSelect } from "@repo/rdx-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { useTranslation } from "../i18n";
|
||||
|
||||
const taxesList = [
|
||||
{ label: "IVA 21%", value: "iva_21", group: "IVA" },
|
||||
{ label: "IVA 10%", value: "iva_10", group: "IVA" },
|
||||
{ label: "IVA 7,5%", value: "iva_7_5", group: "IVA" },
|
||||
{ label: "IVA 5%", value: "iva_5", group: "IVA" },
|
||||
{ label: "IVA 4%", value: "iva_4", group: "IVA" },
|
||||
{ label: "IVA 2%", value: "iva_2", group: "IVA" },
|
||||
{ label: "IVA 0%", value: "iva_0", group: "IVA" },
|
||||
{ label: "Exenta", value: "iva_exenta", group: "IVA" },
|
||||
{ label: "No sujeto", value: "iva_no_sujeto", group: "IVA" },
|
||||
{ label: "Iva Intracomunitario Bienes", value: "iva_intracomunitario_bienes", group: "IVA" },
|
||||
{ label: "Iva Intracomunitario Servicio", value: "iva_intracomunitario_servicio", group: "IVA" },
|
||||
{ label: "Exportación", value: "iva_exportacion", group: "IVA" },
|
||||
{ label: "Inv. Suj. Pasivo", value: "iva_inversion_sujeto_pasivo", group: "IVA" },
|
||||
|
||||
{ label: "Retención 35%", value: "retencion_35", group: "Retención" },
|
||||
{ label: "Retención 19%", value: "retencion_19", group: "Retención" },
|
||||
{ label: "Retención 15%", value: "retencion_15", group: "Retención" },
|
||||
{ label: "Retención 7%", value: "retencion_7", group: "Retención" },
|
||||
{ label: "Retención 2%", value: "retencion_2", group: "Retención" },
|
||||
|
||||
{ label: "REC 5,2%", value: "rec_5_2", group: "Recargo de equivalencia" },
|
||||
{ label: "REC 1,75%", value: "rec_1_75", group: "Recargo de equivalencia" },
|
||||
{ label: "REC 1,4%", value: "rec_1_4", group: "Recargo de equivalencia" },
|
||||
{ label: "REC 1%", value: "rec_1", group: "Recargo de equivalencia" },
|
||||
{ label: "REC 0,62%", value: "rec_0_62", group: "Recargo de equivalencia" },
|
||||
{ label: "REC 0,5%", value: "rec_0_5", group: "Recargo de equivalencia" },
|
||||
{ label: "REC 0,26%", value: "rec_0_26", group: "Recargo de equivalencia" },
|
||||
{ label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" },
|
||||
];
|
||||
|
||||
interface CustomerTaxesMultiSelect {
|
||||
value: string[];
|
||||
onChange: (selectedValues: string[]) => void;
|
||||
[key: string]: any; // Allow other props to be passed
|
||||
}
|
||||
|
||||
export const CustomerTaxesMultiSelect = (props: CustomerTaxesMultiSelect) => {
|
||||
const { value, onChange, ...otherProps } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOnChange = (selectedValues: string[]) => {
|
||||
onChange(selectedValues);
|
||||
};
|
||||
|
||||
const handleValidateOption = (candidateValue: string) => {
|
||||
const exists = (value || []).some((item) => item.startsWith(candidateValue.substring(0, 3)));
|
||||
if (exists) {
|
||||
alert(t("components.customer_invoice_taxes_multi_select.invalid_tax_selection"));
|
||||
}
|
||||
return exists === false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", "max-w-md")}>
|
||||
<MultiSelect
|
||||
options={taxesList}
|
||||
onValueChange={handleOnChange}
|
||||
onValidateOption={handleValidateOption}
|
||||
defaultValue={value}
|
||||
placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")}
|
||||
variant='inverted'
|
||||
animation={0}
|
||||
maxCount={3}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { CustomersProvider } from "../context";
|
||||
|
||||
export const CustomersLayout = ({ children }: PropsWithChildren) => {
|
||||
return <CustomersProvider>{children}</CustomersProvider>;
|
||||
};
|
||||
83
modules/customers/src/web/components/customers-list-grid.tsx
Normal file
83
modules/customers/src/web/components/customers-list-grid.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
||||
// Grid
|
||||
import type { ColDef, GridOptions, ValueFormatterParams } from "ag-grid-community";
|
||||
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
import { MoneyDTO } from "@erp/core";
|
||||
import { formatDate, formatMoney } from "@erp/core/client";
|
||||
// Core CSS
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { useCustomersQuery } from "../hooks";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { CustomerStatusBadge } from "./customer-status-badge";
|
||||
|
||||
// Create new GridExample component
|
||||
export const CustomersListGrid = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isPending, isError, error } = useCustomersQuery({});
|
||||
|
||||
// Column Definitions: Defines & controls grid columns.
|
||||
const [colDefs] = useState<ColDef[]>([
|
||||
{
|
||||
field: "invoice_status",
|
||||
filter: true,
|
||||
headerName: t("pages.list.grid_columns.invoice_status"),
|
||||
cellRenderer: (params: ValueFormatterParams) => {
|
||||
return <CustomerStatusBadge status={params.value} />;
|
||||
},
|
||||
},
|
||||
|
||||
{ field: "invoice_number", headerName: t("pages.list.grid_columns.invoice_number") },
|
||||
{ field: "invoice_series", headerName: t("pages.list.grid_columns.invoice_series") },
|
||||
|
||||
{
|
||||
field: "issue_date",
|
||||
headerName: t("pages.list.grid_columns.issue_date"),
|
||||
valueFormatter: (params: ValueFormatterParams) => {
|
||||
return formatDate(params.value);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "total_price",
|
||||
headerName: t("pages.list.grid_columns.total_price"),
|
||||
valueFormatter: (params: ValueFormatterParams) => {
|
||||
const rawValue: MoneyDTO = params.value;
|
||||
return formatMoney(rawValue);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const gridOptions: GridOptions = {
|
||||
columnDefs: colDefs,
|
||||
defaultColDef: {
|
||||
editable: true,
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
filter: false,
|
||||
sortable: false,
|
||||
resizable: true,
|
||||
},
|
||||
pagination: true,
|
||||
paginationPageSize: 10,
|
||||
paginationPageSizeSelector: [10, 20, 30, 50],
|
||||
localeText: AG_GRID_LOCALE_ES,
|
||||
rowSelection: { mode: "multiRow" },
|
||||
};
|
||||
|
||||
// Container: Defines the grid's theme & dimensions.
|
||||
return (
|
||||
<div
|
||||
className='ag-theme-alpine'
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<AgGridReact rowData={data?.items ?? []} loading={isLoading || isPending} {...gridOptions} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
55
modules/customers/src/web/context/customers-context.tsx
Normal file
55
modules/customers/src/web/context/customers-context.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { PropsWithChildren, createContext } from "react";
|
||||
|
||||
/**
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
* 💡 Posibles usos del InvoicingContext
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
* Este contexto se diseña para encapsular estado y lógica compartida dentro del
|
||||
* bounded context de facturación (facturas), proporcionando acceso global a datos
|
||||
* o funciones relevantes para múltiples vistas (listado, detalle, edición, etc).
|
||||
*
|
||||
* ✅ Usos recomendados:
|
||||
*
|
||||
* 1. 🔎 Gestión de filtros globales:
|
||||
* - Permite que los filtros aplicados en el listado de facturas se conserven
|
||||
* cuando el usuario navega a otras vistas (detalle, edición) y luego regresa.
|
||||
* - Mejora la experiencia de usuario evitando la necesidad de reestablecer filtros.
|
||||
*
|
||||
* 2. 🛡️ Gestión de permisos o configuración de acciones disponibles:
|
||||
* - Permite definir qué acciones están habilitadas para el usuario actual
|
||||
* (crear, editar, eliminar).
|
||||
* - Útil para mostrar u ocultar botones de acción en diferentes pantallas.
|
||||
*
|
||||
* 3. 🧭 Control del layout:
|
||||
* - Si el layout tiene elementos dinámicos (tabs, breadcrumb, loading global),
|
||||
* este contexto puede coordinar su estado desde componentes hijos.
|
||||
* - Ejemplo: seleccionar una pestaña activa que aplica en todas las subrutas.
|
||||
*
|
||||
* 4. 📦 Cacheo liviano de datos compartidos:
|
||||
* - Puede almacenar la última factura abierta, borradores de edición,
|
||||
* o referencias temporales para operaciones CRUD sin tener que usar la URL.
|
||||
*
|
||||
* 5. 🚀 Coordinación de side-effects:
|
||||
* - Permite exponer funciones comunes como `refetch`, `resetFilters`,
|
||||
* o `notifyInvoiceChanged`, usadas desde cualquier subcomponente del dominio.
|
||||
*
|
||||
* ⚠️ Alternativas:
|
||||
* - Si el estado compartido es muy mutable, grande o requiere persistencia,
|
||||
* podría ser preferible usar Zustand o Redux Toolkit.
|
||||
* - No usar contextos para valores que cambian frecuentemente en tiempo real,
|
||||
* ya que pueden causar renders innecesarios.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
export type CustomersContextType = {};
|
||||
|
||||
export type CustomersContextParamsType = {
|
||||
//service: CustomerService;
|
||||
};
|
||||
|
||||
export const CustomersContext = createContext<CustomersContextType>({});
|
||||
|
||||
export const CustomersProvider = ({ children }: PropsWithChildren) => {
|
||||
return <CustomersContext.Provider value={{}}>{children}</CustomersContext.Provider>;
|
||||
};
|
||||
61
modules/customers/src/web/customer-routes.tsx
Normal file
61
modules/customers/src/web/customer-routes.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { ModuleClientParams } from "@erp/core/client";
|
||||
import { lazy } from "react";
|
||||
import { Outlet, RouteObject } from "react-router-dom";
|
||||
|
||||
// Lazy load components
|
||||
const CustomersLayout = lazy(() =>
|
||||
import("./components").then((m) => ({ default: m.CustomersLayout }))
|
||||
);
|
||||
|
||||
const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersList })));
|
||||
|
||||
const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreate })));
|
||||
|
||||
//const LogoutPage = lazy(() => import("./app").then((m) => ({ default: m.LogoutPage })));
|
||||
|
||||
/*const DealerLayout = lazy(() => import("./app").then((m) => ({ default: m.DealerLayout })));
|
||||
const DealersList = lazy(() => import("./app").then((m) => ({ default: m.DealersList })));
|
||||
|
||||
const LoginPageWithLanguageSelector = lazy(() =>
|
||||
import("./app").then((m) => ({ default: m.LoginPageWithLanguageSelector }))
|
||||
);
|
||||
|
||||
|
||||
const CustomerEdit = lazy(() => import("./app").then((m) => ({ default: m.CustomerEdit })));
|
||||
const SettingsEditor = lazy(() => import("./app").then((m) => ({ default: m.SettingsEditor })));
|
||||
const SettingsLayout = lazy(() => import("./app").then((m) => ({ default: m.SettingsLayout })));
|
||||
const CatalogLayout = lazy(() => import("./app").then((m) => ({ default: m.CatalogLayout })));
|
||||
const CatalogList = lazy(() => import("./app").then((m) => ({ default: m.CatalogList })));
|
||||
const DashboardPage = lazy(() => import("./app").then((m) => ({ default: m.DashboardPage })));
|
||||
const CustomersLayout = lazy(() => import("./app").then((m) => ({ default: m.CustomersLayout })));
|
||||
const CustomersList = lazy(() => import("./app").then((m) => ({ default: m.CustomersList })));*/
|
||||
|
||||
export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
||||
return [
|
||||
{
|
||||
path: "customers",
|
||||
element: (
|
||||
<CustomersLayout>
|
||||
<Outlet context={params} />
|
||||
</CustomersLayout>
|
||||
),
|
||||
children: [
|
||||
{ path: "", index: true, element: <CustomersList /> }, // index
|
||||
{ path: "list", element: <CustomersList /> },
|
||||
{ path: "create", element: <CustomerAdd /> },
|
||||
|
||||
//
|
||||
/*{ path: "create", element: <CustomersList /> },
|
||||
{ path: ":id", element: <CustomersList /> },
|
||||
{ path: ":id/edit", element: <CustomersList /> },
|
||||
{ path: ":id/delete", element: <CustomersList /> },
|
||||
{ path: ":id/view", element: <CustomersList /> },
|
||||
{ path: ":id/print", element: <CustomersList /> },
|
||||
{ path: ":id/email", element: <CustomersList /> },
|
||||
{ path: ":id/download", element: <CustomersList /> },
|
||||
{ path: ":id/duplicate", element: <CustomersList /> },
|
||||
{ path: ":id/preview", element: <CustomersList /> },*/
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user