Facturas de cliente y clientes
This commit is contained in:
parent
5b7ee437ff
commit
4b93815985
@ -74,7 +74,7 @@ export class CreateAccountUseCase {
|
||||
|
||||
const validatedData: IAccountProps = {
|
||||
status: AccountStatus.createInactive(),
|
||||
isFreelancer: dto.is_freelancer,
|
||||
isFreelancer: dto.is_companyr,
|
||||
name: dto.name,
|
||||
tradeName: dto.trade_name ? Maybe.some(dto.trade_name) : Maybe.none(),
|
||||
tin: tinOrError.data,
|
||||
|
||||
@ -46,8 +46,8 @@ export class UpdateAccountUseCase {
|
||||
const errors: Error[] = [];
|
||||
const validatedData: Partial<IAccountProps> = {};
|
||||
|
||||
if (dto.is_freelancer) {
|
||||
validatedData.isFreelancer = dto.is_freelancer;
|
||||
if (dto.is_companyr) {
|
||||
validatedData.isFreelancer = dto.is_companyr;
|
||||
}
|
||||
|
||||
if (dto.name) {
|
||||
|
||||
@ -22,7 +22,7 @@ const mockAccountRepository: IAccountRepository = {
|
||||
|
||||
const sampleAccountPrimitives = {
|
||||
id: "c5743279-e1cf-4dd5-baae-6698c8c6183c",
|
||||
is_freelancer: false,
|
||||
is_companyr: false,
|
||||
name: "Empresa XYZ",
|
||||
trade_name: "XYZ Trading",
|
||||
tin: "123456789",
|
||||
@ -72,7 +72,7 @@ const accountBuilder = (accountData: any) => {
|
||||
|
||||
const validatedData: IAccountProps = {
|
||||
status: AccountStatus.createInactive(),
|
||||
isFreelancer: sampleAccountPrimitives.is_freelancer,
|
||||
isFreelancer: sampleAccountPrimitives.is_companyr,
|
||||
name: sampleAccountPrimitives.name,
|
||||
tradeName: sampleAccountPrimitives.trade_name
|
||||
? Maybe.some(sampleAccountPrimitives.trade_name)
|
||||
|
||||
@ -14,7 +14,7 @@ const mockAccountRepository: IAccountRepository = {
|
||||
|
||||
const sampleAccount = {
|
||||
id: "c5743279-e1cf-4dd5-baae-6698c8c6183c",
|
||||
is_freelancer: false,
|
||||
is_companyr: false,
|
||||
name: "Empresa XYZ",
|
||||
trade_name: "XYZ Trading",
|
||||
tin: "123456789",
|
||||
|
||||
@ -53,7 +53,7 @@ export class AccountMapper
|
||||
return Account.create(
|
||||
{
|
||||
status: statusOrError.data,
|
||||
isFreelancer: source.is_freelancer,
|
||||
isFreelancer: source.is_companyr,
|
||||
name: source.name,
|
||||
tradeName: source.trade_name ? Maybe.some(source.trade_name) : Maybe.none(),
|
||||
tin: tinOrError.data,
|
||||
@ -75,7 +75,7 @@ export class AccountMapper
|
||||
public mapToPersistence(source: Account, params?: MapperParamsType): AccountCreationAttributes {
|
||||
return {
|
||||
id: source.id.toPrimitive(),
|
||||
is_freelancer: source.isFreelancer,
|
||||
is_companyr: source.isFreelancer,
|
||||
name: source.name,
|
||||
trade_name: source.tradeName.getOrUndefined(),
|
||||
tin: source.tin.toPrimitive(),
|
||||
|
||||
@ -17,7 +17,7 @@ export class AccountModel extends Model<InferAttributes<AccountModel>, AccountCr
|
||||
|
||||
declare id: string;
|
||||
|
||||
declare is_freelancer: boolean;
|
||||
declare is_companyr: boolean;
|
||||
declare name: string;
|
||||
declare trade_name: CreationOptional<string>;
|
||||
declare tin: string;
|
||||
@ -49,7 +49,7 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
},
|
||||
is_freelancer: {
|
||||
is_companyr: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
},
|
||||
|
||||
@ -10,7 +10,7 @@ export const createAccountPresenter: ICreateAccountPresenter = {
|
||||
toDTO: (account: Account): ICreateAccountResponseDTO => ({
|
||||
id: ensureString(account.id.toString()),
|
||||
|
||||
is_freelancer: ensureBoolean(account.isFreelancer),
|
||||
is_companyr: ensureBoolean(account.isFreelancer),
|
||||
name: ensureString(account.name),
|
||||
trade_name: ensureString(account.tradeName.getOrUndefined()),
|
||||
tin: ensureString(account.tin.toString()),
|
||||
|
||||
@ -10,7 +10,7 @@ export const getAccountPresenter: IGetAccountPresenter = {
|
||||
toDTO: (account: Account): IGetAccountResponseDTO => ({
|
||||
id: ensureString(account.id.toPrimitive()),
|
||||
|
||||
is_freelancer: ensureBoolean(account.isFreelancer),
|
||||
is_companyr: ensureBoolean(account.isFreelancer),
|
||||
name: ensureString(account.name),
|
||||
trade_name: ensureString(account.tradeName.getOrUndefined()),
|
||||
tin: ensureString(account.tin.toPrimitive()),
|
||||
|
||||
@ -11,7 +11,7 @@ export const listAccountsPresenter: IListAccountsPresenter = {
|
||||
accounts.map((account) => ({
|
||||
id: ensureString(account.id.toString()),
|
||||
|
||||
is_freelancer: ensureBoolean(account.isFreelancer),
|
||||
is_companyr: ensureBoolean(account.isFreelancer),
|
||||
name: ensureString(account.name),
|
||||
trade_name: ensureString(account.tradeName.getOrUndefined()),
|
||||
tin: ensureString(account.tin.toString()),
|
||||
|
||||
@ -10,7 +10,7 @@ export const updateAccountPresenter: IUpdateAccountPresenter = {
|
||||
toDTO: (account: Account): IUpdateAccountResponseDTO => ({
|
||||
id: ensureString(account.id.toString()),
|
||||
|
||||
is_freelancer: ensureBoolean(account.isFreelancer),
|
||||
is_companyr: ensureBoolean(account.isFreelancer),
|
||||
name: ensureString(account.name),
|
||||
trade_name: ensureString(account.tradeName.getOrUndefined()),
|
||||
tin: ensureString(account.tin.toString()),
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
export type IListAccountsRequestDTO = {}
|
||||
export type IListAccountsRequestDTO = {};
|
||||
|
||||
export interface ICreateAccountRequestDTO {
|
||||
id: string;
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
@ -27,7 +27,7 @@ export interface ICreateAccountRequestDTO {
|
||||
}
|
||||
|
||||
export interface IUpdateAccountRequestDTO {
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export interface IListAccountsResponseDTO {
|
||||
id: string;
|
||||
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
@ -29,7 +29,7 @@ export interface IListAccountsResponseDTO {
|
||||
export interface IGetAccountResponseDTO {
|
||||
id: string;
|
||||
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
@ -57,7 +57,7 @@ export interface IGetAccountResponseDTO {
|
||||
export interface ICreateAccountResponseDTO {
|
||||
id: string;
|
||||
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
@ -88,7 +88,7 @@ export interface ICreateAccountResponseDTO {
|
||||
export interface IUpdateAccountResponseDTO {
|
||||
id: string;
|
||||
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
|
||||
@ -7,7 +7,7 @@ export const IGetAccountRequestSchema = z.object({});
|
||||
export const ICreateAccountRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
|
||||
is_freelancer: z.boolean(),
|
||||
is_companyr: z.boolean(),
|
||||
name: z.string(),
|
||||
trade_name: z.string(),
|
||||
tin: z.string(),
|
||||
@ -35,7 +35,7 @@ export const ICreateAccountRequestSchema = z.object({
|
||||
export const IUpdateAccountRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
|
||||
is_freelancer: z.boolean(),
|
||||
is_companyr: z.boolean(),
|
||||
name: z.string(),
|
||||
trade_name: z.string(),
|
||||
tin: z.string(),
|
||||
|
||||
@ -50,7 +50,7 @@ export class ContactMapper
|
||||
|
||||
return Contact.create(
|
||||
{
|
||||
isFreelancer: source.is_freelancer,
|
||||
isFreelancer: source.is_companyr,
|
||||
reference: source.reference,
|
||||
name: source.name,
|
||||
tradeName: source.trade_name ? Maybe.some(source.trade_name) : Maybe.none(),
|
||||
@ -77,7 +77,7 @@ export class ContactMapper
|
||||
return Result.ok({
|
||||
id: source.id.toString(),
|
||||
reference: source.reference,
|
||||
is_freelancer: source.isFreelancer,
|
||||
is_companyr: source.isFreelancer,
|
||||
name: source.name,
|
||||
trade_name: source.tradeName.getOrUndefined(),
|
||||
tin: source.tin.toString(),
|
||||
|
||||
@ -21,7 +21,7 @@ export class ContactModel extends Model<
|
||||
declare id: string;
|
||||
declare reference: CreationOptional<string>;
|
||||
|
||||
declare is_freelancer: boolean;
|
||||
declare is_companyr: boolean;
|
||||
declare name: string;
|
||||
declare trade_name: CreationOptional<string>;
|
||||
declare tin: string;
|
||||
@ -56,7 +56,7 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
is_freelancer: {
|
||||
is_companyr: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
},
|
||||
|
||||
@ -12,7 +12,7 @@ export const listContactsPresenter: IListContactsPresenter = {
|
||||
id: ensureString(contact.id.toString()),
|
||||
reference: ensureString(contact.reference),
|
||||
|
||||
is_freelancer: ensureBoolean(contact.isFreelancer),
|
||||
is_companyr: ensureBoolean(contact.isFreelancer),
|
||||
name: ensureString(contact.name),
|
||||
trade_name: ensureString(contact.tradeName.getOrUndefined()),
|
||||
tin: ensureString(contact.tin.toString()),
|
||||
|
||||
@ -2,7 +2,7 @@ export interface IListContactsResponseDTO {
|
||||
id: string;
|
||||
reference: string;
|
||||
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
|
||||
@ -21,7 +21,7 @@ export class CustomerModel extends Model<
|
||||
declare id: string;
|
||||
declare reference: CreationOptional<string>;
|
||||
|
||||
declare is_freelancer: boolean;
|
||||
declare is_companyr: boolean;
|
||||
declare name: string;
|
||||
declare trade_name: CreationOptional<string>;
|
||||
declare tin: string;
|
||||
@ -56,7 +56,7 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
is_freelancer: {
|
||||
is_companyr: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
},
|
||||
|
||||
@ -13,7 +13,7 @@ export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
|
||||
id: ensureString(customer.id.toString()),
|
||||
/*reference: ensureString(customer.),
|
||||
|
||||
is_freelancer: ensureBoolean(customer.isFreelancer),
|
||||
is_companyr: ensureBoolean(customer.isFreelancer),
|
||||
name: ensureString(customer.name),
|
||||
trade_name: ensureString(customer.tradeName.getValue()),
|
||||
tin: ensureString(customer.tin.toString()),
|
||||
|
||||
@ -2,7 +2,7 @@ export interface IListCustomerInvoicesResponseDTO {
|
||||
id: string;
|
||||
/*reference: string;
|
||||
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
@ -26,4 +26,4 @@ export interface IListCustomerInvoicesResponseDTO {
|
||||
currency_code: string;*/
|
||||
}
|
||||
|
||||
export type IGetCustomerInvoiceResponseDTO = {}
|
||||
export type IGetCustomerInvoiceResponseDTO = {};
|
||||
|
||||
@ -2,7 +2,7 @@ export interface IListCustomersResponseDTO {
|
||||
id: string;
|
||||
reference: string;
|
||||
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
|
||||
@ -79,7 +79,7 @@
|
||||
"entry": ["src/index.ts"],
|
||||
"outDir": "dist",
|
||||
"format": ["esm", "cjs"],
|
||||
"target": "es2020",
|
||||
"target": "ES2022",
|
||||
"sourcemap": true,
|
||||
"clean": true,
|
||||
"dts": true,
|
||||
|
||||
@ -33,7 +33,7 @@ export const App = () => {
|
||||
baseURL: import.meta.env.VITE_API_SERVER_URL,
|
||||
getAccessToken,
|
||||
onAuthError: () => {
|
||||
console.error("Error de autenticación");
|
||||
console.error("APP, Error de autenticación");
|
||||
clearAccessToken();
|
||||
//window.location.href = "/login"; // o usar navegación programática
|
||||
},
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export interface IListInvoicesRequestDTO {}
|
||||
export type IListInvoicesRequestDTO = {};
|
||||
|
||||
export interface ICreateInvoiceRequestDTO {
|
||||
id: string;
|
||||
@ -12,7 +12,7 @@ export interface ICreateInvoiceRequestDTO {
|
||||
}
|
||||
|
||||
export interface IUpdateInvoiceRequestDTO {
|
||||
is_freelancer: boolean;
|
||||
is_companyr: boolean;
|
||||
name: string;
|
||||
trade_name: string;
|
||||
tin: string;
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
},
|
||||
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
12
modules/core/src/api/application/errors/application-error.ts
Normal file
12
modules/core/src/api/application/errors/application-error.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Errores de capa de aplicación. No deben "filtrarse" a cliente tal cual.
|
||||
*
|
||||
* */
|
||||
|
||||
export class ApplicationError extends Error {
|
||||
public readonly layer = "application" as const;
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
1
modules/core/src/api/application/errors/index.ts
Normal file
1
modules/core/src/api/application/errors/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./application-error";
|
||||
1
modules/core/src/api/application/index.ts
Normal file
1
modules/core/src/api/application/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./errors";
|
||||
12
modules/core/src/api/domain/errors/domain-error.ts
Normal file
12
modules/core/src/api/domain/errors/domain-error.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Errores de capa de dominio. No deben "filtrarse" a cliente tal cual.
|
||||
*
|
||||
* */
|
||||
|
||||
export class DomainError extends Error {
|
||||
public readonly layer = "domain" as const;
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Clase DomainValidationError
|
||||
* Representa un error de validación de dominio.
|
||||
*
|
||||
* Esta clase extiende la clase Error de JavaScript y se utiliza para manejar errores
|
||||
* específicos de validación dentro del dominio de la aplicación. Permite identificar
|
||||
* el código de error, el campo afectado y un detalle descriptivo del error.
|
||||
*
|
||||
* @class DomainValidationError
|
||||
* @extends {Error}
|
||||
* @property {string} code - Código del error de validación.
|
||||
* @property {string} field - Campo afectado por el error de validación.
|
||||
* @property {string} detail - Detalle descriptivo del error de validación.
|
||||
*
|
||||
* @example
|
||||
* const error = new DomainValidationError("INVALID_EMAIL", "email", "El email no es válido");
|
||||
* console.error(error);
|
||||
*/
|
||||
|
||||
import { DomainError } from "./domain-error";
|
||||
|
||||
export class DomainValidationError extends DomainError {
|
||||
// Discriminante estable para mapeo/telemetría
|
||||
public readonly kind = "VALIDATION" as const;
|
||||
|
||||
constructor(
|
||||
public readonly code: string, // id de regla del negocio (ej. 'INVALID_FORMAT')
|
||||
public readonly field: string, // path: 'number' | 'date' | 'lines[0].quantity'
|
||||
public readonly detail: string, // mensaje legible del negocio
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(`[${field}] ${detail}`, options);
|
||||
this.name = "DomainValidationError";
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
// Constructores rápidos
|
||||
static required(field: string, options?: ErrorOptions) {
|
||||
return new DomainValidationError("REQUIRED", field, "cannot be empty", options);
|
||||
}
|
||||
static invalidFormat(field: string, detail = "invalid format", options?: ErrorOptions) {
|
||||
return new DomainValidationError("INVALID_FORMAT", field, detail, options);
|
||||
}
|
||||
|
||||
// Proyección útil para Problem+JSON o colecciones
|
||||
toDetail() {
|
||||
return { path: this.field, message: this.detail, rule: this.code };
|
||||
}
|
||||
}
|
||||
|
||||
export const isDomainValidationError = (e: unknown): e is DomainValidationError =>
|
||||
e instanceof DomainValidationError;
|
||||
11
modules/core/src/api/domain/errors/duplicate-entity-error.ts
Normal file
11
modules/core/src/api/domain/errors/duplicate-entity-error.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { DomainError } from "./domain-error";
|
||||
|
||||
export class DuplicateEntityError extends DomainError {
|
||||
constructor(entity: string, field: string, value: string, options?: ErrorOptions) {
|
||||
super(`Entity '${entity}' with field '${field}' and value '${value}' already exists.`, options);
|
||||
this.name = "DuplicateEntityError";
|
||||
}
|
||||
}
|
||||
|
||||
export const isDuplicateEntityError = (e: unknown): e is DuplicateEntityError =>
|
||||
e instanceof DuplicateEntityError;
|
||||
11
modules/core/src/api/domain/errors/entity-not-found-error.ts
Normal file
11
modules/core/src/api/domain/errors/entity-not-found-error.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { DomainError } from "./domain-error";
|
||||
|
||||
export class EntityNotFoundError extends DomainError {
|
||||
constructor(entity: string, field: string, value: string, options?: ErrorOptions) {
|
||||
super(`Entity '${entity}' with ${field} '${value}' was not found.`, options);
|
||||
this.name = "EntityNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export const isEntityNotFoundError = (e: unknown): e is EntityNotFoundError =>
|
||||
e instanceof EntityNotFoundError;
|
||||
5
modules/core/src/api/domain/errors/index.ts
Normal file
5
modules/core/src/api/domain/errors/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./domain-error";
|
||||
export * from "./domain-validation-error";
|
||||
export * from "./duplicate-entity-error";
|
||||
export * from "./entity-not-found-error";
|
||||
export * from "./validation-error-collection";
|
||||
@ -15,19 +15,30 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { DomainError } from "./domain-error";
|
||||
|
||||
export interface ValidationErrorDetail {
|
||||
path: string; // ejemplo: "lines[1].unitPrice.amount"
|
||||
message: string; // ejemplo: "Amount must be a positive number"
|
||||
message: string; // ejemplo: "Amount must be a positive number",
|
||||
}
|
||||
|
||||
export class ValidationErrorCollection extends Error {
|
||||
/**
|
||||
* Error de validación múltiple. Agrega varios fallos de una sola vez.
|
||||
*/
|
||||
|
||||
export class ValidationErrorCollection extends DomainError {
|
||||
public readonly code = "VALIDATION" as const;
|
||||
public readonly details: ValidationErrorDetail[];
|
||||
|
||||
constructor(details: ValidationErrorDetail[]) {
|
||||
super("Validation failed");
|
||||
constructor(message: string, details: ValidationErrorDetail[], options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
Object.setPrototypeOf(this, ValidationErrorCollection.prototype);
|
||||
|
||||
this.name = "ValidationErrorCollection";
|
||||
this.details = details;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export const isValidationErrorCollection = (e: unknown): e is ValidationErrorCollection =>
|
||||
e instanceof ValidationErrorCollection;
|
||||
1
modules/core/src/api/domain/index.ts
Normal file
1
modules/core/src/api/domain/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./errors";
|
||||
@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Clase DomainValidationError
|
||||
* Representa un error de validación de dominio.
|
||||
*
|
||||
* Esta clase extiende la clase Error de JavaScript y se utiliza para manejar errores
|
||||
* específicos de validación dentro del dominio de la aplicación. Permite identificar
|
||||
* el código de error, el campo afectado y un detalle descriptivo del error.
|
||||
*
|
||||
* @class DomainValidationError
|
||||
* @extends {Error}
|
||||
* @property {string} code - Código del error de validación.
|
||||
* @property {string} field - Campo afectado por el error de validación.
|
||||
* @property {string} detail - Detalle descriptivo del error de validación.
|
||||
*
|
||||
* @example
|
||||
* const error = new DomainValidationError("INVALID_EMAIL", "email", "El email no es válido");
|
||||
* console.error(error);
|
||||
*/
|
||||
|
||||
export class DomainValidationError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
public readonly field: string,
|
||||
public readonly detail: string
|
||||
) {
|
||||
super(`[${field}] ${detail}`);
|
||||
this.name = "DomainValidationError";
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export class DuplicateEntityError extends Error {
|
||||
constructor(entity: string, id: string) {
|
||||
super(`Entity '${entity}' with ID '${id}' already exists.`);
|
||||
this.name = "DuplicateEntityError";
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export class EntityNotFoundError extends Error {
|
||||
constructor(entity: string, id: string | number) {
|
||||
super(`Entity '${entity}' with ID '${id}' was not found.`);
|
||||
this.name = "EntityNotFoundError";
|
||||
}
|
||||
}
|
||||
@ -1,136 +0,0 @@
|
||||
import {
|
||||
ConnectionError,
|
||||
DatabaseError,
|
||||
ForeignKeyConstraintError,
|
||||
UniqueConstraintError,
|
||||
ValidationError,
|
||||
} from "sequelize";
|
||||
|
||||
import { ApiError } from "./api-error";
|
||||
import { ConflictApiError } from "./conflict-api-error";
|
||||
import { DomainValidationError } from "./domain-validation-error";
|
||||
import { DuplicateEntityError } from "./duplicate-entity-error";
|
||||
import { EntityNotFoundError } from "./entity-not-found-error";
|
||||
import { ForbiddenApiError } from "./forbidden-api-error";
|
||||
import { InternalApiError } from "./internal-api-error";
|
||||
import { NotFoundApiError } from "./not-found-api-error";
|
||||
import { UnauthorizedApiError } from "./unauthorized-api-error";
|
||||
import { UnavailableApiError } from "./unavailable-api-error";
|
||||
import { ValidationApiError } from "./validation-api-error";
|
||||
import { ValidationErrorCollection } from "./validation-error-collection";
|
||||
|
||||
export const errorMapper = {
|
||||
toDomainError(error: unknown): Error {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
const field = error.errors[0]?.path || "unknown_field";
|
||||
return new Error(`A record with this ${field} already exists.`);
|
||||
}
|
||||
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
return new Error("A referenced entity was not found or is invalid.");
|
||||
}
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return new Error(`Invalid data provided: ${error.message}`);
|
||||
}
|
||||
|
||||
if (error instanceof DatabaseError) {
|
||||
return new Error("Database error occurred.");
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error; // Fallback a error estándar
|
||||
}
|
||||
|
||||
return new Error("Unknown persistence error.");
|
||||
},
|
||||
|
||||
/**
|
||||
* Mapea errores de la aplicación a errores de la API.
|
||||
*
|
||||
* Esta función toma un error de la aplicación y lo convierte en un objeto ApiError
|
||||
* adecuado para enviar como respuesta HTTP. Maneja errores comunes como validación,
|
||||
* conflictos, no encontrados, autenticación y errores de infraestructura.
|
||||
*
|
||||
* @param error - El error de la aplicación a mapear.
|
||||
* @returns Un objeto ApiError que representa el error mapeado.
|
||||
* @example
|
||||
* const error = new Error("Invalid input");
|
||||
* const apiError = errorMapper.toApiError(error);
|
||||
* console.log(apiError);
|
||||
* // Output: ValidationApiError { status: 422, title: 'Validation Failed', detail: 'Invalid input', type: 'https://httpstatuses.com/422' }
|
||||
* @throws {ApiError} Si el error no puede ser mapeado a un tipo conocido.
|
||||
* @see ApiError
|
||||
* @see ValidationApiError
|
||||
*/
|
||||
toApiError: (error: Error): ApiError => {
|
||||
const message = error.message || "An unexpected error occurred";
|
||||
|
||||
// 1. 🔍 Errores de validación complejos (agrupados)
|
||||
if (error instanceof ValidationErrorCollection) {
|
||||
return new ValidationApiError(error.message, error.details);
|
||||
}
|
||||
|
||||
// 2. 🔍 Errores individuales de validación de dominio
|
||||
if (error instanceof DomainValidationError) {
|
||||
return new ValidationApiError(error.detail, [{ path: error.field, message: error.detail }]);
|
||||
}
|
||||
|
||||
if (error instanceof DuplicateEntityError) {
|
||||
return new ConflictApiError(error.message);
|
||||
}
|
||||
|
||||
if (error instanceof EntityNotFoundError) {
|
||||
return new NotFoundApiError(error.message);
|
||||
}
|
||||
|
||||
// 3. 🔍 Errores individuales de validación
|
||||
if (
|
||||
message.includes("invalid") ||
|
||||
message.includes("is not valid") ||
|
||||
message.includes("must be") ||
|
||||
message.includes("cannot be") ||
|
||||
message.includes("empty")
|
||||
) {
|
||||
return new ValidationApiError(message);
|
||||
}
|
||||
|
||||
// 4. 🔍 Recurso no encontrado
|
||||
if (error.name === "NotFoundError" || message.includes("not found")) {
|
||||
return new NotFoundApiError(message);
|
||||
}
|
||||
|
||||
// 5. 🔍 Conflicto (por ejemplo, duplicado)
|
||||
if (
|
||||
error.name === "ConflictError" ||
|
||||
error instanceof UniqueConstraintError ||
|
||||
message.includes("already exists") ||
|
||||
message.includes("duplicate key")
|
||||
) {
|
||||
return new ConflictApiError(message);
|
||||
}
|
||||
|
||||
// 6. 🔍 No autenticado
|
||||
if (error.name === "UnauthorizedError" || message.includes("unauthorized")) {
|
||||
return new UnauthorizedApiError(message);
|
||||
}
|
||||
|
||||
// 7. 🔍 Prohibido
|
||||
if (error.name === "ForbiddenError" || message.includes("forbidden")) {
|
||||
return new ForbiddenApiError(message);
|
||||
}
|
||||
|
||||
// 8. 🔍 Error de conexión o indisponibilidad de servicio
|
||||
if (
|
||||
error instanceof ConnectionError ||
|
||||
message.includes("Database connection lost") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("ECONNREFUSED")
|
||||
) {
|
||||
return new UnavailableApiError("Service temporarily unavailable.");
|
||||
}
|
||||
|
||||
// 9. 🔍 Fallback: error no identificado
|
||||
return new InternalApiError(`Unexpected error: ${message}`);
|
||||
},
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./errors";
|
||||
export * from "./application";
|
||||
export * from "./domain";
|
||||
export * from "./infrastructure";
|
||||
export * from "./logger";
|
||||
export * from "./modules";
|
||||
|
||||
3
modules/core/src/api/infrastructure/errors/index.ts
Normal file
3
modules/core/src/api/infrastructure/errors/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./infrastructure-errors";
|
||||
export * from "./infrastructure-repository-error";
|
||||
export * from "./infrastructure-unavailable-error";
|
||||
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Errores de capa de infraestructura. No deben "filtrarse" a cliente tal cual.
|
||||
* Se usan para decidir el código HTTP y para observabilidad (logs/tracing). */
|
||||
|
||||
export class InfrastructureError extends Error {
|
||||
public readonly layer = "infrastructure" as const;
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { InfrastructureError } from "./infrastructure-errors";
|
||||
|
||||
export class InfrastructureRepositoryError extends InfrastructureError {
|
||||
public readonly code = "REPOSITORY_ERROR" as const;
|
||||
constructor(message = "Repository operation failed", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
export const isInfrastructureRepositoryError = (e: unknown): e is InfrastructureRepositoryError =>
|
||||
e instanceof InfrastructureRepositoryError;
|
||||
@ -0,0 +1,11 @@
|
||||
import { InfrastructureError } from "./infrastructure-errors";
|
||||
|
||||
export class InfrastructureUnavailableError extends InfrastructureError {
|
||||
public readonly code = "UNAVAILABLE" as const;
|
||||
constructor(message = "Underlying service temporarily unavailable", options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
export const isInfrastructureUnavailableError = (e: unknown): e is InfrastructureUnavailableError =>
|
||||
e instanceof InfrastructureUnavailableError;
|
||||
188
modules/core/src/api/infrastructure/express/api-error-mapper.ts
Normal file
188
modules/core/src/api/infrastructure/express/api-error-mapper.ts
Normal file
@ -0,0 +1,188 @@
|
||||
// Clase para mapear errores de Dominio/Aplicación/Infraestructura → ApiError (Problem+JSON).
|
||||
// - Inmutable (register() devuelve una NUEVA instancia).
|
||||
// - Extensible por reglas con prioridad.
|
||||
// - Sin dependencias de vendors (p.ej. Sequelize).
|
||||
//
|
||||
// ─ Convenciones de carpetas sugeridas:
|
||||
// domain/errors/* → errores semánticos (DDD)
|
||||
// application/errors/* → errores de aplicación (servicios, lógica de negocio)
|
||||
// infrastructure/errors/* → errores técnicos (DB, red, timeouts)
|
||||
// infrastructure/express/errors/* → ApiError (RFC7807) y familia
|
||||
//
|
||||
// Nota: Todos los nombres de tipos/clases/archivos en inglés; comentarios en castellano.
|
||||
|
||||
import {
|
||||
DomainValidationError,
|
||||
DuplicateEntityError,
|
||||
EntityNotFoundError,
|
||||
ValidationErrorCollection,
|
||||
isDomainValidationError,
|
||||
isDuplicateEntityError,
|
||||
isEntityNotFoundError,
|
||||
isValidationErrorCollection,
|
||||
} from "../../domain";
|
||||
import { isInfrastructureRepositoryError, isInfrastructureUnavailableError } from "../errors";
|
||||
import {
|
||||
ApiError,
|
||||
ConflictApiError,
|
||||
ForbiddenApiError,
|
||||
InternalApiError,
|
||||
NotFoundApiError,
|
||||
UnauthorizedApiError,
|
||||
UnavailableApiError,
|
||||
ValidationApiError,
|
||||
} from "./errors";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// Contexto opcional para enriquecer Problem+JSON (útil en middleware Express)
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
export interface ApiErrorContext {
|
||||
instance?: string; // p.ej. req.originalUrl
|
||||
correlationId?: string; // p.ej. header 'x-correlation-id'
|
||||
method?: string; // GET/POST/PUT/DELETE
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// Regla de mapeo: cómo reconocer y construir un ApiError
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
export interface ErrorToApiRule {
|
||||
priority?: number; // mayor valor ⇒ se evalúa antes (default 0)
|
||||
matches: (e: unknown) => boolean;
|
||||
build: (e: unknown, ctx?: ApiErrorContext) => ApiError;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// ApiErrorMapper (inmutable)
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
export class ApiErrorMapper {
|
||||
private readonly rules: ReadonlyArray<ErrorToApiRule>;
|
||||
private readonly fallback: (e: unknown, ctx?: ApiErrorContext) => ApiError;
|
||||
|
||||
private constructor(
|
||||
rules: ReadonlyArray<ErrorToApiRule>,
|
||||
fallback: (e: unknown, ctx?: ApiErrorContext) => ApiError
|
||||
) {
|
||||
this.rules = [...rules].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
this.fallback = fallback;
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
// Crea un mapper con reglas por defecto (cubren casos comunes)
|
||||
static default(): ApiErrorMapper {
|
||||
return new ApiErrorMapper(defaultRules, defaultFallback);
|
||||
}
|
||||
|
||||
// Registra una regla adicional devolviendo una NUEVA instancia
|
||||
register(rule: ErrorToApiRule): ApiErrorMapper {
|
||||
return new ApiErrorMapper([...this.rules, rule], this.fallback);
|
||||
}
|
||||
|
||||
// Mapea un error a un ApiError evaluando reglas por prioridad
|
||||
map(error: unknown, ctx?: ApiErrorContext): ApiError {
|
||||
for (const rule of this.rules) {
|
||||
try {
|
||||
if (rule.matches(error)) {
|
||||
return rule.build(error, ctx);
|
||||
}
|
||||
} catch {
|
||||
// ⚠️ Una regla no debe tumbar el mapper; continuamos con la siguiente.
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return this.fallback(error, ctx);
|
||||
}
|
||||
|
||||
// Útil en tests / introspección
|
||||
getRules(): ReadonlyArray<ErrorToApiRule> {
|
||||
return this.rules;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// Reglas por defecto (prioridad alta a más específicas)
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
const defaultRules: ReadonlyArray<ErrorToApiRule> = [
|
||||
// 1) Validación múltiple (colección)
|
||||
{
|
||||
priority: 100,
|
||||
matches: (e) => isValidationErrorCollection(e),
|
||||
build: (e) =>
|
||||
new ValidationApiError(
|
||||
(e as ValidationErrorCollection).message,
|
||||
(e as ValidationErrorCollection).details
|
||||
),
|
||||
},
|
||||
|
||||
// 2) Validación de dominio unitaria
|
||||
{
|
||||
priority: 90,
|
||||
matches: (e) => isDomainValidationError(e),
|
||||
build: (e) =>
|
||||
new ValidationApiError((e as DomainValidationError).detail, [
|
||||
{ path: (e as DomainValidationError).field, message: (e as DomainValidationError).detail },
|
||||
]),
|
||||
},
|
||||
|
||||
// 3) Duplicados / conflictos de unicidad
|
||||
{
|
||||
priority: 80,
|
||||
matches: (e) => isDuplicateEntityError(e),
|
||||
build: (e) => new ConflictApiError((e as DuplicateEntityError).message),
|
||||
},
|
||||
|
||||
// 4) No encontrado
|
||||
{
|
||||
priority: 70,
|
||||
matches: (e) => isEntityNotFoundError(e),
|
||||
build: (e) => new NotFoundApiError((e as EntityNotFoundError).message),
|
||||
},
|
||||
|
||||
// 5) Infra transitoria (DB/servicio caído, timeouts)
|
||||
{
|
||||
priority: 60,
|
||||
matches: (e) => isInfrastructureUnavailableError(e),
|
||||
build: () => new UnavailableApiError("Service temporarily unavailable."),
|
||||
},
|
||||
|
||||
// 6) Infra no transitoria (errores de repositorio inesperados)
|
||||
{
|
||||
priority: 50,
|
||||
matches: (e) => isInfrastructureRepositoryError(e),
|
||||
build: () => new InternalApiError("Unexpected repository error."),
|
||||
},
|
||||
|
||||
// 7) Autenticación/autorización por nombre (si no tienes clases dedicadas)
|
||||
{
|
||||
priority: 40,
|
||||
matches: (e): e is Error => e instanceof Error && e.name === "UnauthorizedError",
|
||||
build: (e) => new UnauthorizedApiError((e as Error).message || "Unauthorized"),
|
||||
},
|
||||
{
|
||||
priority: 40,
|
||||
matches: (e): e is Error => e instanceof Error && e.name === "ForbiddenError",
|
||||
build: (e) => new ForbiddenApiError((e as Error).message || "Forbidden"),
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback genérico (500)
|
||||
function defaultFallback(e: unknown): ApiError {
|
||||
const message = typeof (e as any)?.message === "string" ? (e as any).message : "Unexpected error";
|
||||
return new InternalApiError(`Unexpected error: ${message}`);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// Serializador opcional a Problem+JSON (si tu ApiError no lo trae ya)
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
export function toProblemJson(apiError: ApiError, ctx?: ApiErrorContext) {
|
||||
const maybeErrors = (apiError as any).errors ? { errors: (apiError as any).errors } : {};
|
||||
return {
|
||||
type: apiError.type,
|
||||
title: apiError.title,
|
||||
status: apiError.status,
|
||||
detail: apiError.detail,
|
||||
...(ctx?.instance ? { instance: ctx.instance } : {}),
|
||||
...(ctx?.correlationId ? { correlationId: ctx.correlationId } : {}),
|
||||
...(ctx?.method ? { method: ctx.method } : {}),
|
||||
...maybeErrors,
|
||||
};
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import { InfrastructureError } from "../../errors";
|
||||
|
||||
interface IApiErrorOptions {
|
||||
status: number;
|
||||
title: string;
|
||||
@ -8,7 +10,7 @@ interface IApiErrorOptions {
|
||||
[key: string]: any; // Para permitir añadir campos extra
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
export class ApiError extends InfrastructureError {
|
||||
public status: number;
|
||||
public title: string;
|
||||
public detail: string;
|
||||
@ -9,4 +9,4 @@ export class ConflictApiError extends ApiError {
|
||||
type: "https://httpstatuses.com/409",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,4 +9,4 @@ export class ForbiddenApiError extends ApiError {
|
||||
type: "https://httpstatuses.com/403",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,8 @@
|
||||
export * from "./api-error";
|
||||
export * from "./conflict-api-error";
|
||||
export * from "./domain-validation-error";
|
||||
export * from "./duplicate-entity-error";
|
||||
export * from "./entity-not-found-error";
|
||||
export * from "./error-mapper";
|
||||
export * from "./forbidden-api-error";
|
||||
export * from "./internal-api-error";
|
||||
export * from "./not-found-api-error";
|
||||
export * from "./unauthorized-api-error";
|
||||
export * from "./unavailable-api-error";
|
||||
export * from "./validation-api-error";
|
||||
export * from "./validation-error-collection";
|
||||
@ -9,4 +9,4 @@ export class InternalApiError extends ApiError {
|
||||
type: "https://httpstatuses.com/500",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,4 +9,4 @@ export class NotFoundApiError extends ApiError {
|
||||
type: "https://httpstatuses.com/404",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,4 +9,4 @@ export class UnauthorizedApiError extends ApiError {
|
||||
type: "https://httpstatuses.com/401",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,4 +9,4 @@ export class UnavailableApiError extends ApiError {
|
||||
type: "https://httpstatuses.com/503",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,4 +10,4 @@ export class ValidationApiError extends ApiError {
|
||||
errors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ import {
|
||||
UnauthorizedApiError,
|
||||
UnavailableApiError,
|
||||
ValidationApiError,
|
||||
} from "../../errors";
|
||||
} from "./errors";
|
||||
|
||||
type GuardResultLike = { isFailure: boolean; error?: ApiError };
|
||||
export type GuardContext = {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ForbiddenApiError, UnauthorizedApiError, ValidationApiError } from "../../errors";
|
||||
import { ForbiddenApiError, UnauthorizedApiError, ValidationApiError } from "./errors";
|
||||
import { GuardContext, GuardFn, guardFail, guardOk } from "./express-controller";
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export * from "./api-error-mapper";
|
||||
export * from "./errors";
|
||||
export * from "./express-controller";
|
||||
export * from "./express-guards";
|
||||
export * from "./middlewares";
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { ApiError } from "../../../errors/api-error";
|
||||
import { ApiErrorMapper, toProblemJson } from "../api-error-mapper";
|
||||
import { ApiError } from "../errors/api-error";
|
||||
|
||||
export const globalErrorHandler = async (
|
||||
error: Error,
|
||||
@ -11,6 +12,19 @@ export const globalErrorHandler = async (
|
||||
if (res.headersSent) {
|
||||
return next(error);
|
||||
}
|
||||
const ctx = {
|
||||
instance: req.originalUrl,
|
||||
correlationId: (req.headers["x-correlation-id"] as string) || undefined,
|
||||
method: req.method,
|
||||
};
|
||||
|
||||
const apiError = ApiErrorMapper.map(err, ctx);
|
||||
const body = toProblemJson(apiError, ctx);
|
||||
|
||||
// 👇 Log interno con cause/traza (no lo exponemos al cliente)
|
||||
// logger.error({ err, cause: (err as any)?.cause, ...ctx }, `❌ Unhandled API error: ${error.message}`);
|
||||
|
||||
res.status(apiError.status).json(body);
|
||||
|
||||
//logger.error(`❌ Unhandled API error: ${error.message}`);
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { RequestHandler } from "express";
|
||||
import { ZodSchema } from "zod/v4";
|
||||
import { InternalApiError, ValidationApiError } from "../../../errors";
|
||||
import { InternalApiError, ValidationApiError } from "../errors";
|
||||
import { ExpressController } from "../express-controller";
|
||||
|
||||
/**
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./database";
|
||||
export * from "./errors";
|
||||
export * from "./express";
|
||||
export * from "./sequelize";
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./sequelize-error-translator";
|
||||
export * from "./sequelize-mapper";
|
||||
export * from "./sequelize-repository";
|
||||
export * from "./sequelize-transaction-manager";
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
import {
|
||||
ConnectionError,
|
||||
DatabaseError,
|
||||
ForeignKeyConstraintError,
|
||||
ValidationError as SequelizeValidationError,
|
||||
UniqueConstraintError,
|
||||
} from "sequelize";
|
||||
import {
|
||||
DomainValidationError,
|
||||
DuplicateEntityError,
|
||||
EntityNotFoundError,
|
||||
ValidationErrorCollection,
|
||||
} from "../../domain";
|
||||
import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-error";
|
||||
import { InfrastructureUnavailableError } from "../errors/infrastructure-unavailable-error";
|
||||
|
||||
/**
|
||||
* Traduce errores específicos de Sequelize a errores de dominio/infraestructura
|
||||
* entendibles por el resto de capas.
|
||||
*
|
||||
* 👉 Este traductor pertenece a la infraestructura (persistencia)
|
||||
*/
|
||||
export function translateSequelizeError(err: unknown): Error {
|
||||
// 1) Duplicados (índices únicos)
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
// Tomamos el primer detalle (puede haber varios)
|
||||
const detail = err.errors?.[0];
|
||||
const entity = detail?.instance?.constructor.name ?? "unknown_entity";
|
||||
const value = detail?.value ?? "unknown_value";
|
||||
const field = detail?.path ?? "unknown_field";
|
||||
|
||||
// ⚠️ Si los nombres de campo son sensibles, normaliza/whitelistea antes de exponerlos a dominio
|
||||
return new DuplicateEntityError(entity, field, value, { cause: err });
|
||||
}
|
||||
|
||||
// 2) Violación de FK → error de validación de dominio (referencia inválida)
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
// Sequelize expone `fields` (obj) y `index`. Extraemos el campo si está.
|
||||
const entity = err.index ?? "unknown_entity";
|
||||
const field = err.fields ? Object.keys(err.fields)[0] : "foreign_key";
|
||||
const value = err.fields ? Object.values(err.fields)[0] : "unknown_value";
|
||||
return new EntityNotFoundError(entity, field, value, { cause: err });
|
||||
}
|
||||
|
||||
// 3) Validaciones de Sequelize (pueden venir varias)
|
||||
if (err instanceof SequelizeValidationError) {
|
||||
const details = (err.errors ?? []).map((e) => ({
|
||||
path: e.path ?? "unknown",
|
||||
message: e.message,
|
||||
// Algunas props útiles: e.validatorKey / e.validatorName
|
||||
rule: (e as any).validatorKey ?? undefined,
|
||||
}));
|
||||
|
||||
// Si sólo hay 1, puedes preferir DomainValidationError
|
||||
if (details.length === 1) {
|
||||
const d = details[0];
|
||||
return DomainValidationError.invalidFormat(d.path, d.message, { cause: err });
|
||||
}
|
||||
|
||||
return new ValidationErrorCollection("Invalid data provided", details, { cause: err });
|
||||
}
|
||||
|
||||
// 4) Conectividad / indisponibilidad (transitorio)
|
||||
if (err instanceof ConnectionError) {
|
||||
return new InfrastructureUnavailableError("Database connection unavailable", { cause: err });
|
||||
}
|
||||
|
||||
// 5) Otros errores de base de datos (no transitorios)
|
||||
if (err instanceof DatabaseError) {
|
||||
return new InfrastructureRepositoryError("Database error occurred", { cause: err });
|
||||
}
|
||||
|
||||
// 6) Fallback: deja pasar si ya es un Error tipado de tu app, si no wrap
|
||||
if (err instanceof Error) return err;
|
||||
|
||||
return new InfrastructureRepositoryError("Unknown persistence error", { cause: err as any });
|
||||
}
|
||||
@ -7,9 +7,9 @@
|
||||
},
|
||||
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ export class DeleteCustomerInvoiceUseCase {
|
||||
}
|
||||
|
||||
if (!existsCheck.data) {
|
||||
return Result.fail(new EntityNotFoundError("CustomerInvoice", id.toString()));
|
||||
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
|
||||
}
|
||||
|
||||
return await this.service.deleteById(invoiceId, transaction);
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
// Ejemplo: regla específica para Billing → InvoiceIdAlreadyExistsError
|
||||
// (si defines un error más ubicuo dentro del BC con su propia clase)
|
||||
|
||||
import { DomainError } from "@erp/core/api";
|
||||
|
||||
// Suponemos que existe esta clase en tu dominio de Billing:
|
||||
export class CustomerInvoiceIdAlreadyExistsError extends DomainError {
|
||||
public readonly code = "DUPLICATE_INVOICE_ID" as const;
|
||||
}
|
||||
1
modules/customer-invoices/src/api/domain/errors/index.ts
Normal file
1
modules/customer-invoices/src/api/domain/errors/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer-invoice-id-already-exits-error";
|
||||
@ -1,5 +1,6 @@
|
||||
export * from "./aggregates";
|
||||
export * from "./entities";
|
||||
export * from "./errors";
|
||||
export * from "./repositories";
|
||||
export * from "./services";
|
||||
export * from "./value-objects";
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
// Ejemplo: regla específica para Billing → InvoiceIdAlreadyExistsError
|
||||
// (si defines un error más ubicuo dentro del BC con su propia clase)
|
||||
|
||||
import { ApiErrorMapper, ConflictApiError, ErrorToApiRule } from "@erp/core/api";
|
||||
import { CustomerInvoiceIdAlreadyExistsError } from "../../domain";
|
||||
|
||||
// Crea una regla específica (prioridad alta para sobreescribir mensajes)
|
||||
const invoiceDuplicateRule: ErrorToApiRule = {
|
||||
priority: 120,
|
||||
matches: (e): e is CustomerInvoiceIdAlreadyExistsError =>
|
||||
e instanceof CustomerInvoiceIdAlreadyExistsError,
|
||||
build: (e) =>
|
||||
new ConflictApiError(
|
||||
(e as CustomerInvoiceIdAlreadyExistsError).message ||
|
||||
"Invoice with the provided id already exists."
|
||||
),
|
||||
};
|
||||
|
||||
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
|
||||
export const customerInvoicesApiErrorMapper: ApiErrorMapper =
|
||||
ApiErrorMapper.default().register(invoiceDuplicateRule);
|
||||
@ -7,9 +7,9 @@
|
||||
},
|
||||
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
@ -6,6 +6,11 @@ import { ICustomerService } from "../../domain";
|
||||
import { mapDTOToCustomerProps } from "../helpers";
|
||||
import { CreateCustomersAssembler } from "./assembler";
|
||||
|
||||
type CreateCustomerUseCaseInput = {
|
||||
tenantId: string;
|
||||
dto: CreateCustomerCommandDTO;
|
||||
};
|
||||
|
||||
export class CreateCustomerUseCase {
|
||||
constructor(
|
||||
private readonly service: ICustomerService,
|
||||
@ -13,7 +18,9 @@ export class CreateCustomerUseCase {
|
||||
private readonly assembler: CreateCustomersAssembler
|
||||
) {}
|
||||
|
||||
public execute(dto: CreateCustomerCommandDTO) {
|
||||
public execute(params: CreateCustomerUseCaseInput) {
|
||||
const { dto, tenantId: companyId } = params;
|
||||
|
||||
const customerPropsOrError = mapDTOToCustomerProps(dto);
|
||||
|
||||
if (customerPropsOrError.isFailure) {
|
||||
@ -42,7 +49,7 @@ export class CreateCustomerUseCase {
|
||||
return Result.fail(new DuplicateEntityError("Customer", id.toString()));
|
||||
}
|
||||
|
||||
const result = await this.service.save(newCustomer, transaction);
|
||||
const result = await this.service.save(newCustomer, idCompany, transaction);
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
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";
|
||||
@ -21,17 +20,17 @@ export class DeleteCustomerUseCase {
|
||||
|
||||
return this.transactionManager.complete(async (transaction) => {
|
||||
try {
|
||||
const existsCheck = await this.service.existsById(id, transaction);
|
||||
const existsCheck = await this.service.existsByIdInCompany(id, transaction);
|
||||
|
||||
if (existsCheck.isFailure) {
|
||||
return Result.fail(existsCheck.error);
|
||||
}
|
||||
|
||||
if (!existsCheck.data) {
|
||||
return Result.fail(new EntityNotFoundError("Customer", id.toString()));
|
||||
return Result.fail(new EntityNotFoundError("Customer", "id", id.toString()));
|
||||
}
|
||||
|
||||
return await this.service.deleteById(id, transaction);
|
||||
return await this.service.deleteCustomerByIdInCompany(id, transaction);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ export class GetCustomerAssembler {
|
||||
id: customer.id.toPrimitive(),
|
||||
reference: customer.reference,
|
||||
|
||||
is_freelancer: customer.isFreelancer,
|
||||
is_companyr: customer.isFreelancer,
|
||||
name: customer.name,
|
||||
trade_name: customer.tradeName.getOrUndefined(),
|
||||
tin: customer.tin.toPrimitive(),
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
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 { GetCustomerAssembler } from "./assembler";
|
||||
|
||||
type GetCustomerUseCaseInput = {
|
||||
tenantId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export class GetCustomerUseCase {
|
||||
constructor(
|
||||
private readonly service: ICustomerService,
|
||||
@ -12,16 +16,28 @@ export class GetCustomerUseCase {
|
||||
private readonly assembler: GetCustomerAssembler
|
||||
) {}
|
||||
|
||||
public execute(dto: GetCustomerByIdQueryDTO) {
|
||||
const idOrError = UniqueID.create(dto.id);
|
||||
public execute(params: GetCustomerUseCaseInput) {
|
||||
const { id, tenantId: companyId } = params;
|
||||
|
||||
const idOrError = UniqueID.create(id);
|
||||
|
||||
if (idOrError.isFailure) {
|
||||
return Result.fail(idOrError.error);
|
||||
}
|
||||
|
||||
const companyIdOrError = UniqueID.create(companyId);
|
||||
|
||||
if (companyIdOrError.isFailure) {
|
||||
return Result.fail(companyIdOrError.error);
|
||||
}
|
||||
|
||||
return this.transactionManager.complete(async (transaction) => {
|
||||
try {
|
||||
const customerOrError = await this.service.getById(idOrError.data, transaction);
|
||||
const customerOrError = await this.service.getCustomerByIdInCompany(
|
||||
companyIdOrError.data,
|
||||
idOrError.data,
|
||||
transaction
|
||||
);
|
||||
if (customerOrError.isFailure) {
|
||||
return Result.fail(customerOrError.error);
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export class ListCustomersAssembler {
|
||||
id: customer.id.toPrimitive(),
|
||||
reference: customer.reference,
|
||||
|
||||
is_freelancer: customer.isFreelancer,
|
||||
is_companyr: customer.isFreelancer,
|
||||
name: customer.name,
|
||||
trade_name: customer.tradeName.getOrUndefined() || "",
|
||||
tin: customer.tin.toString(),
|
||||
|
||||
@ -9,8 +9,9 @@ import {
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
export interface CustomerProps {
|
||||
companyId: UniqueID;
|
||||
reference: string;
|
||||
isFreelancer: boolean;
|
||||
isCompany: boolean;
|
||||
name: string;
|
||||
tin: TINNumber;
|
||||
address: PostalAddress;
|
||||
@ -29,6 +30,7 @@ export interface CustomerProps {
|
||||
|
||||
export interface ICustomer {
|
||||
id: UniqueID;
|
||||
companyId: UniqueID;
|
||||
reference: string;
|
||||
name: string;
|
||||
tin: TINNumber;
|
||||
@ -44,8 +46,8 @@ export interface ICustomer {
|
||||
fax: Maybe<PhoneNumber>;
|
||||
website: Maybe<string>;
|
||||
|
||||
isCustomer: boolean;
|
||||
isFreelancer: boolean;
|
||||
isIndividual: boolean;
|
||||
isCompany: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@ -64,6 +66,15 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
|
||||
return Result.ok(contact);
|
||||
}
|
||||
|
||||
update(partial: Partial<Omit<CustomerProps, "companyId">>): Result<Customer, Error> {
|
||||
const updatedCustomer = new Customer({ ...this.props, ...partial }, this.id);
|
||||
return Result.ok(updatedCustomer);
|
||||
}
|
||||
|
||||
get companyId(): UniqueID {
|
||||
return this.props.companyId;
|
||||
}
|
||||
|
||||
get reference() {
|
||||
return this.props.reference;
|
||||
}
|
||||
@ -116,12 +127,12 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
|
||||
return this.props.currencyCode;
|
||||
}
|
||||
|
||||
get isCustomer(): boolean {
|
||||
return !this.props.isFreelancer;
|
||||
get isIndividual(): boolean {
|
||||
return !this.props.isCompany;
|
||||
}
|
||||
|
||||
get isFreelancer(): boolean {
|
||||
return this.props.isFreelancer;
|
||||
get isCompany(): boolean {
|
||||
return this.props.isCompany;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
|
||||
@ -3,48 +3,50 @@ import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Collection, Result } from "@repo/rdx-utils";
|
||||
import { Customer } from "../aggregates";
|
||||
|
||||
/**
|
||||
* Contrato del repositorio de Customers.
|
||||
* Define la interfaz de persistencia para el agregado `Customer`.
|
||||
* El escopado multitenant está representado por `companyId`.
|
||||
*/
|
||||
export interface ICustomerRepository {
|
||||
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
|
||||
/**
|
||||
* Guarda (crea o actualiza) un Customer en la base de datos.
|
||||
* Retorna el objeto actualizado tras la operación.
|
||||
*/
|
||||
save(customer: Customer, transaction?: any): Promise<Result<Customer, 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>
|
||||
* Comprueba si existe un Customer con un `id` dentro de una `company`.
|
||||
*/
|
||||
save(customer: Customer, transaction: any): Promise<Result<Customer, Error>>;
|
||||
existsByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
id: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<boolean, 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>
|
||||
* Recupera un Customer por su ID y companyId.
|
||||
* Devuelve un `NotFoundError` si no se encuentra.
|
||||
*/
|
||||
findById(id: UniqueID, transaction: any): Promise<Result<Customer, Error>>;
|
||||
getByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
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
|
||||
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
|
||||
* El resultado está encapsulado en un objeto `Collection<T>`.
|
||||
*/
|
||||
findByCriteria(
|
||||
findByCriteriaInCompany(
|
||||
companyId: UniqueID,
|
||||
criteria: Criteria,
|
||||
transaction: any
|
||||
): Promise<Result<Collection<Customer>, Error>>;
|
||||
transaction?: any
|
||||
): Promise<Result<Collection<Customer>>>;
|
||||
|
||||
/**
|
||||
*
|
||||
* 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>
|
||||
* Elimina un Customer por su ID, dentro de una empresa.
|
||||
* Retorna `void` si se elimina correctamente, o `NotFoundError` si no existía.
|
||||
*/
|
||||
deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>>;
|
||||
deleteByIdInCompany(companyId: UniqueID, id: UniqueID, transaction?: any): Promise<Result<void>>;
|
||||
}
|
||||
|
||||
@ -4,30 +4,63 @@ import { Collection, Result } from "@repo/rdx-utils";
|
||||
import { Customer, CustomerProps } from "../aggregates";
|
||||
|
||||
export interface ICustomerService {
|
||||
build(props: CustomerProps, id?: UniqueID): Result<Customer, Error>;
|
||||
/**
|
||||
* Construye un nuevo Customer validando todos sus value objects.
|
||||
*/
|
||||
buildCustomerInCompany(
|
||||
companyId: UniqueID,
|
||||
props: Omit<CustomerProps, "companyId">,
|
||||
customerId?: UniqueID
|
||||
): Result<Customer, Error>;
|
||||
|
||||
save(invoice: Customer, transaction: any): Promise<Result<Customer, Error>>;
|
||||
/**
|
||||
* Guarda un Customer (nuevo o modificado) en base de datos.
|
||||
*/
|
||||
saveCustomerInCompany(customer: Customer, transaction: any): Promise<Result<Customer, Error>>;
|
||||
|
||||
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
|
||||
/**
|
||||
* Comprueba si existe un Customer con ese ID en la empresa indicada.
|
||||
*/
|
||||
existsByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
customerId: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<boolean, Error>>;
|
||||
|
||||
findByCriteria(
|
||||
/**
|
||||
* Lista todos los customers que cumplan el criterio, dentro de una empresa.
|
||||
*/
|
||||
findCustomerByCriteriaInCompany(
|
||||
companyId: UniqueID,
|
||||
criteria: Criteria,
|
||||
transaction?: any
|
||||
): Promise<Result<Collection<Customer>, Error>>;
|
||||
|
||||
getById(id: UniqueID, transaction?: any): Promise<Result<Customer>>;
|
||||
/**
|
||||
* Recupera un Customer por su ID dentro de una empresa.
|
||||
*/
|
||||
getCustomerByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
customerId: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<Customer>>;
|
||||
|
||||
updateById(
|
||||
id: UniqueID,
|
||||
data: Partial<CustomerProps>,
|
||||
/**
|
||||
* Actualiza parcialmente los datos de un Customer.
|
||||
*/
|
||||
updateCustomerByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
customerId: UniqueID,
|
||||
partial: Partial<Omit<CustomerProps, "companyId">>,
|
||||
transaction?: any
|
||||
): Promise<Result<Customer, Error>>;
|
||||
|
||||
createCustomer(
|
||||
id: UniqueID,
|
||||
data: CustomerProps,
|
||||
/**
|
||||
* Elimina un Customer por ID dentro de una empresa.
|
||||
*/
|
||||
deleteCustomerByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
customerId: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<Customer, Error>>;
|
||||
|
||||
deleteById(id: UniqueID, transaction?: any): Promise<Result<void, Error>>;
|
||||
): Promise<Result<void, Error>>;
|
||||
}
|
||||
|
||||
@ -1,23 +1,34 @@
|
||||
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) {}
|
||||
findCustomerByCriteriaInCompany(
|
||||
companyId: UniqueID,
|
||||
criteria: Criteria,
|
||||
transaction?: any
|
||||
): Promise<Result<Collection<Customer>, Error>> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
|
||||
* @param props - Las propiedades ya validadas para crear el cliente.
|
||||
* @param customerId - Identificador UUID del cliente (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);
|
||||
buildCustomerInCompany(
|
||||
companyId: UniqueID,
|
||||
props: Omit<CustomerProps, "companyId">,
|
||||
customerId?: UniqueID
|
||||
): Result<Customer, Error> {
|
||||
return Customer.create({ ...props, companyId }, customerId);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -27,123 +38,107 @@ export class CustomerService implements ICustomerService {
|
||||
* @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);
|
||||
async saveCustomerInCompany(
|
||||
customer: Customer,
|
||||
transaction: any
|
||||
): Promise<Result<Customer, Error>> {
|
||||
return this.repository.save(customer, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Comprueba si existe o no en persistencia una factura con el ID proporcionado
|
||||
* Comprueba si existe o no en persistencia un cliente con el ID proporcionado
|
||||
*
|
||||
* @param id - Identificador UUID de la factura.
|
||||
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||
* @param customerId - Identificador UUID del cliente
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Boolean, Error> - Existe la factura o no.
|
||||
* @returns Result<Boolean, Error> - Existe el cliente o no.
|
||||
*/
|
||||
|
||||
async existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>> {
|
||||
return this.repository.existsById(id, transaction);
|
||||
existsByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
customerId: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<boolean, Error>> {
|
||||
return this.repository.existsByIdInCompany(companyId, customerId, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene una colección de facturas que cumplen con los filtros definidos en un objeto Criteria.
|
||||
* Obtiene una colección de clientes que cumplen con los filtros definidos en un objeto Criteria.
|
||||
*
|
||||
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||
* @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.
|
||||
* @returns Result<Collection<Customer>, Error> - Colección de clientes o error.
|
||||
*/
|
||||
async findByCriteria(
|
||||
async findCustomersByCriteriaInCompany(
|
||||
companyId: UniqueID,
|
||||
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;
|
||||
transaction?: any
|
||||
): Promise<Result<Collection<Customer>>> {
|
||||
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera una factura por su identificador único.
|
||||
* Recupera un cliente por su identificador único.
|
||||
*
|
||||
* @param id - Identificador UUID de la factura.
|
||||
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||
* @param customerId - Identificador UUID del cliente.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error> - Factura encontrada o error.
|
||||
* @returns Result<Customer, Error> - Cliente encontradoF o error.
|
||||
*/
|
||||
async getById(id: UniqueID, transaction?: Transaction): Promise<Result<Customer>> {
|
||||
return await this.repository.findById(id, transaction);
|
||||
async getCustomerByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
customerId: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<Customer>> {
|
||||
return this.repository.getByIdInCompany(companyId, customerId, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza parcialmente una factura existente con nuevos datos.
|
||||
* Actualiza parcialmente un cliente existente con nuevos datos.
|
||||
*
|
||||
* @param id - Identificador de la factura a actualizar.
|
||||
* @param changes - Subconjunto de props válidas para aplicar.
|
||||
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
|
||||
* @param customerId - Identificador del cliente a actualizar.
|
||||
* @param partial - Subconjunto de props válidas para aplicar.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error> - Factura actualizada o error.
|
||||
* @returns Result<Customer, Error> - Cliente actualizado o error.
|
||||
*/
|
||||
async updateById(
|
||||
async updateCustomerByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
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"));
|
||||
partial: Partial<Omit<CustomerProps, "companyId">>,
|
||||
transaction?: any
|
||||
): Promise<Result<Customer>> {
|
||||
const customerResult = await this.getCustomerByIdInCompany(companyId, customerId, transaction);
|
||||
|
||||
if (customerResult.isFailure) {
|
||||
return Result.fail(customerResult.error);
|
||||
}
|
||||
|
||||
return Result.fail(new Error("No implementado"));
|
||||
const customer = customerResult.data;
|
||||
const updatedCustomer = customer.update(partial);
|
||||
|
||||
/*const updatedCustomerOrError = Customer.update(customerOrError.data, data);
|
||||
if (updatedCustomerOrError.isFailure) {
|
||||
return Result.fail(
|
||||
new Error(`Error updating customer: ${updatedCustomerOrError.error.message}`)
|
||||
);
|
||||
if (updatedCustomer.isFailure) {
|
||||
return Result.fail(updatedCustomer.error);
|
||||
}
|
||||
|
||||
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);
|
||||
return this.saveCustomerInCompany(updatedCustomer.data, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina (o marca como eliminada) una factura según su ID.
|
||||
* Elimina (o marca como eliminado) un cliente según su ID.
|
||||
*
|
||||
* @param id - Identificador UUID de la factura.
|
||||
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
|
||||
* @param customerId - Identificador UUID del cliente.
|
||||
* @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);
|
||||
async deleteCustomerByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
customerId: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<void>> {
|
||||
return this.repository.deleteByIdInCompany(companyId, customerId, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
ListCustomersAssembler,
|
||||
ListCustomersUseCase,
|
||||
} from "../application";
|
||||
import { CustomerService } from "../domain";
|
||||
import { CustomerService, ICustomerService } from "../domain";
|
||||
import { CustomerMapper } from "./mappers";
|
||||
import { CustomerRepository } from "./sequelize";
|
||||
|
||||
@ -39,7 +39,7 @@ type CustomerDeps = {
|
||||
|
||||
let _repo: CustomerRepository | null = null;
|
||||
let _mapper: CustomerMapper | null = null;
|
||||
let _service: CustomerService | null = null;
|
||||
let _service: ICustomerService | null = null;
|
||||
let _assemblers: CustomerDeps["assemblers"] | null = null;
|
||||
|
||||
export function getCustomerDependencies(params: ModuleParams): CustomerDeps {
|
||||
|
||||
@ -23,7 +23,7 @@ export class CreateCustomerController extends ExpressController {
|
||||
dto.customerCompanyId = user.companyId;
|
||||
*/
|
||||
|
||||
const result = await this.useCase.execute(dto);
|
||||
const result = await this.useCase.execute({ tenantId, dto });
|
||||
|
||||
return result.match(
|
||||
(data) => this.created(data),
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { enforceTenant } from "@erp/auth/api";
|
||||
import { ILogger, ModuleParams, validateRequest } from "@erp/core/api";
|
||||
import { Application, NextFunction, Request, Response, Router } from "express";
|
||||
import { Sequelize } from "sequelize";
|
||||
@ -28,7 +27,7 @@ export const customersRouter = (params: ModuleParams) => {
|
||||
const deps = getCustomerDependencies(params);
|
||||
|
||||
// 🔐 Autenticación + Tenancy para TODO el router
|
||||
router.use(/* authenticateJWT(), */ enforceTenant() /*checkTabContext*/);
|
||||
//router.use(/* authenticateJWT(), */ enforceTenant() /*checkTabContext*/);
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
|
||||
@ -19,9 +19,10 @@ export class CustomerModel extends Model<
|
||||
}*/
|
||||
|
||||
declare id: string;
|
||||
declare company_id: string;
|
||||
declare reference: CreationOptional<string>;
|
||||
|
||||
declare is_freelancer: boolean;
|
||||
declare is_company: boolean;
|
||||
declare name: string;
|
||||
declare trade_name: CreationOptional<string>;
|
||||
declare tin: string;
|
||||
@ -43,20 +44,28 @@ export class CustomerModel extends Model<
|
||||
declare status: string;
|
||||
declare lang_code: string;
|
||||
declare currency_code: string;
|
||||
|
||||
static associate(database: Sequelize) {}
|
||||
|
||||
static hooks(database: Sequelize) {}
|
||||
}
|
||||
|
||||
export default (sequelize: Sequelize) => {
|
||||
export default (database: Sequelize) => {
|
||||
CustomerModel.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
},
|
||||
company_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
},
|
||||
reference: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
is_freelancer: {
|
||||
is_company: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
},
|
||||
@ -149,7 +158,7 @@ export default (sequelize: Sequelize) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
sequelize: database,
|
||||
tableName: "customers",
|
||||
|
||||
paranoid: true, // softs deletes
|
||||
@ -160,8 +169,8 @@ export default (sequelize: Sequelize) => {
|
||||
deletedAt: "deleted_at",
|
||||
|
||||
indexes: [
|
||||
{ name: "company_idx", fields: ["company_id"], unique: false },
|
||||
{ name: "email_idx", fields: ["email"], unique: true },
|
||||
{ name: "reference_idx", fields: ["reference"], unique: true },
|
||||
],
|
||||
|
||||
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
|
||||
|
||||
@ -19,81 +19,108 @@ export class CustomerRepository
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
async existsById(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
|
||||
/**
|
||||
*
|
||||
* Guarda un nuevo cliente o actualiza uno existente.
|
||||
*
|
||||
* @param customer - El cliente a guardar.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error>
|
||||
*/
|
||||
async save(customer: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
|
||||
try {
|
||||
const result = await this._exists(CustomerModel, "id", id.toString(), transaction);
|
||||
|
||||
return Result.ok(Boolean(result));
|
||||
const data = this.mapper.mapToPersistence(customer);
|
||||
const [instance] = await CustomerModel.upsert(data, { transaction, returning: true });
|
||||
return this.mapper.mapToDomain(instance);
|
||||
} catch (err: unknown) {
|
||||
return Result.fail(errorMapper.toDomainError(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba si existe un Customer con un `id` dentro de una `company`.
|
||||
*
|
||||
* Persiste una nueva factura o actualiza una existente.
|
||||
*
|
||||
* @param invoice - El agregado a guardar.
|
||||
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||
* @param id - Identificador UUID del cliente.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error>
|
||||
* @returns Result<boolean, Error>
|
||||
*/
|
||||
async save(invoice: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
|
||||
async existsByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
id: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<boolean, 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));
|
||||
const count = await CustomerModel.count({
|
||||
where: { id: id.toString(), company_id: companyId.toString() },
|
||||
transaction,
|
||||
});
|
||||
return Result.ok(Boolean(count > 0));
|
||||
} catch (error: any) {
|
||||
return Result.fail(errorMapper.toDomainError(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera un cliente por su ID y companyId.
|
||||
*
|
||||
* Busca una factura por su identificador único.
|
||||
* @param id - UUID de la factura.
|
||||
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||
* @param id - Identificador UUID del cliente.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer, Error>
|
||||
*/
|
||||
async findById(id: UniqueID, transaction: Transaction): Promise<Result<Customer, Error>> {
|
||||
async getByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
id: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<Customer, Error>> {
|
||||
try {
|
||||
const rawData = await this._findById(CustomerModel, id.toString(), { transaction });
|
||||
const row = await CustomerModel.findOne({
|
||||
where: { id: id.toString(), company_id: companyId.toString() },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!rawData) {
|
||||
return Result.fail(new Error(`Invoice with id ${id} not found.`));
|
||||
if (!row) {
|
||||
return Result.fail(new Error(`Customer ${id.toString()} not found`));
|
||||
}
|
||||
|
||||
return this.mapper.mapToDomain(rawData);
|
||||
} catch (err: unknown) {
|
||||
return Result.fail(errorMapper.toDomainError(err));
|
||||
return this.mapper.mapToDomain(row);
|
||||
} catch (error: any) {
|
||||
return Result.fail(errorMapper.toDomainError(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
|
||||
*
|
||||
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
|
||||
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||
* @param criteria - Criterios de búsqueda.
|
||||
* @param transaction - Transacción activa para la operación.
|
||||
* @returns Result<Customer[], Error>
|
||||
* @returns Result<Collection<Customer>, Error>
|
||||
*
|
||||
* @see Criteria
|
||||
*/
|
||||
public async findByCriteria(
|
||||
async findByCriteriaInCompany(
|
||||
companyId: UniqueID,
|
||||
criteria: Criteria,
|
||||
transaction: Transaction
|
||||
): Promise<Result<Collection<Customer>, Error>> {
|
||||
transaction?: any
|
||||
): Promise<Result<Collection<Customer>>> {
|
||||
try {
|
||||
const converter = new CriteriaToSequelizeConverter();
|
||||
const query = converter.convert(criteria);
|
||||
|
||||
console.debug({ criteria, transaction, query, CustomerModel });
|
||||
query.where = {
|
||||
...query.where,
|
||||
company_id: companyId.toString(),
|
||||
};
|
||||
|
||||
console.debug({ model: "CustomerModel", criteria, query });
|
||||
|
||||
const instances = await CustomerModel.findAll({
|
||||
...query,
|
||||
transaction,
|
||||
});
|
||||
|
||||
console.debug(instances);
|
||||
|
||||
return this.mapper.mapArrayToDomain(instances);
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
@ -103,16 +130,30 @@ export class CustomerRepository
|
||||
|
||||
/**
|
||||
*
|
||||
* Elimina o marca como eliminada una factura.
|
||||
* @param id - UUID de la factura a eliminar.
|
||||
* Elimina o marca como eliminado un cliente.
|
||||
*
|
||||
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||
* @param id - UUID del cliente 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>> {
|
||||
async deleteByIdInCompany(
|
||||
companyId: UniqueID,
|
||||
id: UniqueID,
|
||||
transaction?: any
|
||||
): Promise<Result<void>> {
|
||||
try {
|
||||
await this._deleteById(CustomerModel, id, false, transaction);
|
||||
const deleted = await CustomerModel.destroy({
|
||||
where: { id: id.toString(), company_id: companyId.toString() },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (deleted === 0) {
|
||||
return Result.fail(new Error(`Customer with id ${id} not found in company ${companyId}.`));
|
||||
}
|
||||
return Result.ok<void>();
|
||||
} catch (err: unknown) {
|
||||
// , `Error deleting customer ${id} in company ${companyId}`
|
||||
return Result.fail(errorMapper.toDomainError(err));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,9 @@ import * as z from "zod/v4";
|
||||
|
||||
export const CreateCustomerRequestSchema = z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
reference: z.string().optional(),
|
||||
|
||||
is_freelancer: z.boolean(),
|
||||
is_company: z.boolean(),
|
||||
name: z.string(),
|
||||
trade_name: z.string(),
|
||||
tin: z.string(),
|
||||
@ -22,7 +22,7 @@ export const CreateCustomerRequestSchema = z.object({
|
||||
|
||||
legal_record: z.string(),
|
||||
|
||||
default_tax: z.number(),
|
||||
default_tax: z.array(z.string()),
|
||||
status: z.string(),
|
||||
lang_code: z.string(),
|
||||
currency_code: z.string(),
|
||||
|
||||
@ -5,7 +5,7 @@ export const CustomerCreationResponseSchema = z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
|
||||
is_freelancer: z.boolean(),
|
||||
is_companyr: z.boolean(),
|
||||
name: z.string(),
|
||||
trade_name: z.string(),
|
||||
tin: z.string(),
|
||||
|
||||
@ -6,7 +6,7 @@ export const CustomerListResponseSchema = createListViewResponseSchema(
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
|
||||
is_freelancer: z.boolean(),
|
||||
is_companyr: z.boolean(),
|
||||
name: z.string(),
|
||||
trade_name: z.string(),
|
||||
tin: z.string(),
|
||||
|
||||
@ -5,7 +5,7 @@ export const GetCustomerByIdResponseSchema = z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
|
||||
is_freelancer: z.boolean(),
|
||||
is_companyr: z.boolean(),
|
||||
name: z.string(),
|
||||
trade_name: z.string(),
|
||||
tin: z.string(),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
|
||||
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
import { useBlocker, useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useCreateCustomerMutation } from "../../hooks/use-create-customer-mutation";
|
||||
import { useTranslation } from "../../i18n";
|
||||
@ -9,7 +9,6 @@ import { CustomerEditForm } from "./customer-edit-form";
|
||||
export const CustomerCreate = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { block, unblock } = useBlocker(1);
|
||||
|
||||
const { mutate, isPending, isError, error } = useCreateCustomerMutation();
|
||||
|
||||
@ -56,12 +55,17 @@ export const CustomerCreate = () => {
|
||||
<AppContent>
|
||||
<div className='flex items-center justify-between space-y-2'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>{t("pages.create.title")}</h2>
|
||||
<p className='text-muted-foreground'>{t("pages.create.description")}</p>
|
||||
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
|
||||
{t("pages.create.title")}
|
||||
</h2>
|
||||
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
|
||||
{t("pages.create.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center justify-end mb-4'>
|
||||
<Button className='cursor-pointer' onClick={() => navigate("/customers/list")}>
|
||||
{t("pages.create.back_to_list")}
|
||||
<BackHistoryButton />
|
||||
<Button type='submit' className='cursor-pointer'>
|
||||
{t("pages.create.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,13 +4,18 @@ import { useForm } from "react-hook-form";
|
||||
import { TaxesMultiSelectField } from "@erp/core/components";
|
||||
import { SelectField, TextAreaField, TextField } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
Label,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
@ -21,10 +26,24 @@ import { CustomerData, CustomerDataFormSchema } from "./customer.schema";
|
||||
|
||||
const defaultCustomerData = {
|
||||
id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
|
||||
is_company: true,
|
||||
status: "active",
|
||||
name: "1",
|
||||
language_code: "ES",
|
||||
currency: "EUR",
|
||||
tin: "B12345678",
|
||||
name: "Pepe",
|
||||
trade_name: "Pepe's Shop",
|
||||
email: "pepe@example.com",
|
||||
phone: "+34 123 456 789",
|
||||
website: "https://pepe.com",
|
||||
fax: "+34 123 456 789",
|
||||
street: "Calle Falsa 123",
|
||||
city: "Madrid",
|
||||
country: "ES",
|
||||
postal_code: "28080",
|
||||
state: "Madrid",
|
||||
lang_code: "es",
|
||||
currency_code: "EUR",
|
||||
legal_record: "Registro Mercantil de Madrid, Tomo 12345, Folio 67, Hoja M-123456",
|
||||
default_tax: ["iva_21", "rec_5_2"],
|
||||
};
|
||||
|
||||
interface CustomerFormProps {
|
||||
@ -71,44 +90,49 @@ export const CustomerEditForm = ({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit, handleError)}>
|
||||
<div className='grid grid-cols-1 space-y-6'>
|
||||
<div className='w-full grid grid-cols-1 space-y-8 space-x-8 xl:grid-cols-2'>
|
||||
{/* Información básica */}
|
||||
<Card className='border-0 shadow-none'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
|
||||
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
|
||||
<div className='space-y-3 xl:col-span-2'>
|
||||
<Label className='text-sm font-medium'>
|
||||
{t("form_fields.customer_type.label")}
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={"customer_type"}
|
||||
onValueChange={(value: "company" | "individual") => {
|
||||
// Usar setValue del form
|
||||
form.setValue("customer_type", value);
|
||||
}}
|
||||
className='flex gap-6'
|
||||
>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem
|
||||
value='company'
|
||||
id='company'
|
||||
{...form.register("customer_type")}
|
||||
/>
|
||||
<Label htmlFor='company'>{t("form_fields.customer_type.company")}</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem
|
||||
value='individual'
|
||||
id='individual'
|
||||
{...form.register("customer_type")}
|
||||
/>
|
||||
<Label htmlFor='individual'>{t("form_fields.customer_type.individual")}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='is_company'
|
||||
render={({ field }) => (
|
||||
<FormItem className='space-y-3'>
|
||||
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value ? "1" : "0"}
|
||||
className='flex gap-6'
|
||||
>
|
||||
<FormItem className='flex items-center space-x-2'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={"1"} />
|
||||
</FormControl>
|
||||
<FormLabel className='font-normal'>
|
||||
{t("form_fields.customer_type.company")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className='flex items-center space-x-2'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={"0"} />
|
||||
</FormControl>
|
||||
<FormLabel className='font-normal'>
|
||||
{t("form_fields.customer_type.individual")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
control={form.control}
|
||||
@ -140,7 +164,6 @@ export const CustomerEditForm = ({
|
||||
<TextField
|
||||
control={form.control}
|
||||
name='reference'
|
||||
required
|
||||
label={t("form_fields.reference.label")}
|
||||
placeholder={t("form_fields.reference.placeholder")}
|
||||
description={t("form_fields.reference.description")}
|
||||
@ -149,7 +172,7 @@ export const CustomerEditForm = ({
|
||||
</Card>
|
||||
|
||||
{/* Dirección */}
|
||||
<Card className='border-0 shadow-none'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("form_groups.address.title")}</CardTitle>
|
||||
<CardDescription>{t("form_groups.address.description")}</CardDescription>
|
||||
@ -214,7 +237,7 @@ export const CustomerEditForm = ({
|
||||
</Card>
|
||||
|
||||
{/* Contacto */}
|
||||
<Card className='border-0 shadow-none'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("form_groups.contact_info.title")}</CardTitle>
|
||||
<CardDescription>{t("form_groups.contact_info.description")}</CardDescription>
|
||||
@ -259,7 +282,7 @@ export const CustomerEditForm = ({
|
||||
</Card>
|
||||
|
||||
{/* Configuraciones Adicionales */}
|
||||
<Card className='border-0 shadow-none'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("form_groups.additional_config.title")}</CardTitle>
|
||||
<CardDescription>{t("form_groups.additional_config.description")}</CardDescription>
|
||||
@ -320,6 +343,7 @@ export const CustomerEditForm = ({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Button type='submit'>Submit</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
},
|
||||
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
@ -15,6 +15,6 @@
|
||||
"incremental": false,
|
||||
"declaration": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"target": "es2020"
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,17 +120,17 @@ const data = {
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
title: "Ajustes",
|
||||
url: "#",
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
title: "Get Help",
|
||||
title: "Soporte",
|
||||
url: "#",
|
||||
icon: HelpCircleIcon,
|
||||
},
|
||||
{
|
||||
title: "Search",
|
||||
title: "Buscar",
|
||||
url: "#",
|
||||
icon: SearchIcon,
|
||||
},
|
||||
@ -180,8 +180,8 @@ const data2 = {
|
||||
],
|
||||
navMain: [
|
||||
{
|
||||
title: "Playground",
|
||||
url: "#",
|
||||
title: "Clientes",
|
||||
url: "/customers",
|
||||
icon: SquareTerminal,
|
||||
isActive: true,
|
||||
items: [
|
||||
@ -200,8 +200,8 @@ const data2 = {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
url: "#",
|
||||
title: "Facturas de cliente",
|
||||
url: "/customer-invoices",
|
||||
icon: Bot,
|
||||
items: [
|
||||
{
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { useIsMobile } from "@repo/shadcn-ui/hooks";
|
||||
import { useIsMobile } from "@repo/shadcn-ui/hooks/";
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user