- Crear capa de compatibilidad config/ (database.ts, typeorm.ts, index.ts) - Exportar Tenant y User en core/entities/index.ts - Crear Company entity en auth/entities/ - Crear Warehouse entity y módulo warehouses/ - Corregir ApiKey imports para usar core/entities - Unificar tipos TokenPayload y JwtPayload - Corregir ZodError.errors -> .issues en controllers CRM, inventory, sales, partners - Agregar exports InventoryService e InventoryController - Deshabilitar temporalmente módulo finance (requiere correcciones estructurales) - Agregar .gitignore estándar Build: PASANDO (módulo finance excluido temporalmente) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
import { Response, NextFunction } from 'express';
|
|
import { z } from 'zod';
|
|
import { partnersService, CreatePartnerDto, UpdatePartnerDto, PartnerFilters, PartnerType } from './partners.service.js';
|
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
|
|
|
// Validation schemas (accept both snake_case and camelCase from frontend)
|
|
const createPartnerSchema = z.object({
|
|
code: z.string().min(1, 'El código es requerido').max(20),
|
|
display_name: z.string().min(1).max(200).optional(),
|
|
displayName: z.string().min(1, 'El nombre es requerido').max(200).optional(),
|
|
legal_name: z.string().max(200).optional(),
|
|
legalName: z.string().max(200).optional(),
|
|
partner_type: z.enum(['customer', 'supplier', 'both']).default('customer'),
|
|
partnerType: z.enum(['customer', 'supplier', 'both']).default('customer'),
|
|
email: z.string().email('Email inválido').max(255).optional(),
|
|
phone: z.string().max(30).optional(),
|
|
mobile: z.string().max(30).optional(),
|
|
website: z.string().max(500).optional(),
|
|
tax_id: z.string().max(20).optional(),
|
|
taxId: z.string().max(20).optional(),
|
|
tax_regime: z.string().max(100).optional(),
|
|
taxRegime: z.string().max(100).optional(),
|
|
cfdi_use: z.string().max(10).optional(),
|
|
cfdiUse: z.string().max(10).optional(),
|
|
payment_term_days: z.coerce.number().int().default(0),
|
|
paymentTermDays: z.coerce.number().int().default(0),
|
|
credit_limit: z.coerce.number().default(0),
|
|
creditLimit: z.coerce.number().default(0),
|
|
price_list_id: z.string().uuid().optional(),
|
|
priceListId: z.string().uuid().optional(),
|
|
discount_percent: z.coerce.number().default(0),
|
|
discountPercent: z.coerce.number().default(0),
|
|
category: z.string().max(50).optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
notes: z.string().optional(),
|
|
sales_rep_id: z.string().uuid().optional(),
|
|
salesRepId: z.string().uuid().optional(),
|
|
});
|
|
|
|
const updatePartnerSchema = z.object({
|
|
display_name: z.string().min(1).max(200).optional(),
|
|
displayName: z.string().min(1).max(200).optional(),
|
|
legal_name: z.string().max(200).optional().nullable(),
|
|
legalName: z.string().max(200).optional().nullable(),
|
|
partner_type: z.enum(['customer', 'supplier', 'both']).optional(),
|
|
partnerType: z.enum(['customer', 'supplier', 'both']).optional(),
|
|
email: z.string().email('Email inválido').max(255).optional().nullable(),
|
|
phone: z.string().max(30).optional().nullable(),
|
|
mobile: z.string().max(30).optional().nullable(),
|
|
website: z.string().max(500).optional().nullable(),
|
|
tax_id: z.string().max(20).optional().nullable(),
|
|
taxId: z.string().max(20).optional().nullable(),
|
|
tax_regime: z.string().max(100).optional().nullable(),
|
|
taxRegime: z.string().max(100).optional().nullable(),
|
|
cfdi_use: z.string().max(10).optional().nullable(),
|
|
cfdiUse: z.string().max(10).optional().nullable(),
|
|
payment_term_days: z.coerce.number().int().optional(),
|
|
paymentTermDays: z.coerce.number().int().optional(),
|
|
credit_limit: z.coerce.number().optional(),
|
|
creditLimit: z.coerce.number().optional(),
|
|
price_list_id: z.string().uuid().optional().nullable(),
|
|
priceListId: z.string().uuid().optional().nullable(),
|
|
discount_percent: z.coerce.number().optional(),
|
|
discountPercent: z.coerce.number().optional(),
|
|
category: z.string().max(50).optional().nullable(),
|
|
tags: z.array(z.string()).optional(),
|
|
notes: z.string().optional().nullable(),
|
|
is_active: z.boolean().optional(),
|
|
isActive: z.boolean().optional(),
|
|
is_verified: z.boolean().optional(),
|
|
isVerified: z.boolean().optional(),
|
|
sales_rep_id: z.string().uuid().optional().nullable(),
|
|
salesRepId: z.string().uuid().optional().nullable(),
|
|
});
|
|
|
|
const querySchema = z.object({
|
|
search: z.string().optional(),
|
|
partner_type: z.enum(['customer', 'supplier', 'both']).optional(),
|
|
partnerType: z.enum(['customer', 'supplier', 'both']).optional(),
|
|
category: z.string().optional(),
|
|
is_active: z.coerce.boolean().optional(),
|
|
isActive: z.coerce.boolean().optional(),
|
|
is_verified: z.coerce.boolean().optional(),
|
|
isVerified: z.coerce.boolean().optional(),
|
|
page: z.coerce.number().int().positive().default(1),
|
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
});
|
|
|
|
class PartnersController {
|
|
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
|
try {
|
|
const queryResult = querySchema.safeParse(req.query);
|
|
if (!queryResult.success) {
|
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.issues);
|
|
}
|
|
|
|
const data = queryResult.data;
|
|
const tenantId = req.user!.tenantId;
|
|
const filters: PartnerFilters = {
|
|
search: data.search,
|
|
partnerType: (data.partnerType || data.partner_type) as PartnerType | undefined,
|
|
category: data.category,
|
|
isActive: data.isActive ?? data.is_active,
|
|
isVerified: data.isVerified ?? data.is_verified,
|
|
page: data.page,
|
|
limit: data.limit,
|
|
};
|
|
|
|
const result = await partnersService.findAll(tenantId, filters);
|
|
|
|
const response: ApiResponse = {
|
|
success: true,
|
|
data: result.data,
|
|
meta: {
|
|
total: result.total,
|
|
page: filters.page || 1,
|
|
limit: filters.limit || 20,
|
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
|
},
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
async findCustomers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
|
try {
|
|
const queryResult = querySchema.safeParse(req.query);
|
|
if (!queryResult.success) {
|
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.issues);
|
|
}
|
|
|
|
const data = queryResult.data;
|
|
const tenantId = req.user!.tenantId;
|
|
const filters = {
|
|
search: data.search,
|
|
category: data.category,
|
|
isActive: data.isActive ?? data.is_active,
|
|
isVerified: data.isVerified ?? data.is_verified,
|
|
page: data.page,
|
|
limit: data.limit,
|
|
};
|
|
|
|
const result = await partnersService.findCustomers(tenantId, filters);
|
|
|
|
const response: ApiResponse = {
|
|
success: true,
|
|
data: result.data,
|
|
meta: {
|
|
total: result.total,
|
|
page: filters.page || 1,
|
|
limit: filters.limit || 20,
|
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
|
},
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
async findSuppliers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
|
try {
|
|
const queryResult = querySchema.safeParse(req.query);
|
|
if (!queryResult.success) {
|
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.issues);
|
|
}
|
|
|
|
const data = queryResult.data;
|
|
const tenantId = req.user!.tenantId;
|
|
const filters = {
|
|
search: data.search,
|
|
category: data.category,
|
|
isActive: data.isActive ?? data.is_active,
|
|
isVerified: data.isVerified ?? data.is_verified,
|
|
page: data.page,
|
|
limit: data.limit,
|
|
};
|
|
|
|
const result = await partnersService.findSuppliers(tenantId, filters);
|
|
|
|
const response: ApiResponse = {
|
|
success: true,
|
|
data: result.data,
|
|
meta: {
|
|
total: result.total,
|
|
page: filters.page || 1,
|
|
limit: filters.limit || 20,
|
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
|
},
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
|
try {
|
|
const { id } = req.params;
|
|
const tenantId = req.user!.tenantId;
|
|
const partner = await partnersService.findById(id, tenantId);
|
|
|
|
const response: ApiResponse = {
|
|
success: true,
|
|
data: partner,
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
|
try {
|
|
const parseResult = createPartnerSchema.safeParse(req.body);
|
|
if (!parseResult.success) {
|
|
throw new ValidationError('Datos de contacto inválidos', parseResult.error.issues);
|
|
}
|
|
|
|
const data = parseResult.data;
|
|
const tenantId = req.user!.tenantId;
|
|
const userId = req.user!.userId;
|
|
|
|
// Transform to camelCase DTO
|
|
const dto: CreatePartnerDto = {
|
|
code: data.code,
|
|
displayName: data.displayName || data.display_name || data.code,
|
|
legalName: data.legalName || data.legal_name,
|
|
partnerType: (data.partnerType || data.partner_type) as PartnerType,
|
|
email: data.email,
|
|
phone: data.phone,
|
|
mobile: data.mobile,
|
|
website: data.website,
|
|
taxId: data.taxId || data.tax_id,
|
|
taxRegime: data.taxRegime || data.tax_regime,
|
|
cfdiUse: data.cfdiUse || data.cfdi_use,
|
|
paymentTermDays: data.paymentTermDays || data.payment_term_days,
|
|
creditLimit: data.creditLimit || data.credit_limit,
|
|
priceListId: data.priceListId || data.price_list_id,
|
|
discountPercent: data.discountPercent || data.discount_percent,
|
|
category: data.category,
|
|
tags: data.tags,
|
|
notes: data.notes,
|
|
salesRepId: data.salesRepId || data.sales_rep_id,
|
|
};
|
|
|
|
const partner = await partnersService.create(dto, tenantId, userId);
|
|
|
|
const response: ApiResponse = {
|
|
success: true,
|
|
data: partner,
|
|
message: 'Contacto creado exitosamente',
|
|
};
|
|
|
|
res.status(201).json(response);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
|
try {
|
|
const { id } = req.params;
|
|
const parseResult = updatePartnerSchema.safeParse(req.body);
|
|
if (!parseResult.success) {
|
|
throw new ValidationError('Datos de contacto inválidos', parseResult.error.issues);
|
|
}
|
|
|
|
const data = parseResult.data;
|
|
const tenantId = req.user!.tenantId;
|
|
const userId = req.user!.userId;
|
|
|
|
// Transform to camelCase DTO
|
|
const dto: UpdatePartnerDto = {};
|
|
|
|
if (data.displayName !== undefined || data.display_name !== undefined) {
|
|
dto.displayName = data.displayName ?? data.display_name;
|
|
}
|
|
if (data.legalName !== undefined || data.legal_name !== undefined) {
|
|
dto.legalName = data.legalName ?? data.legal_name;
|
|
}
|
|
if (data.partnerType !== undefined || data.partner_type !== undefined) {
|
|
dto.partnerType = (data.partnerType ?? data.partner_type) as PartnerType;
|
|
}
|
|
if (data.email !== undefined) dto.email = data.email;
|
|
if (data.phone !== undefined) dto.phone = data.phone;
|
|
if (data.mobile !== undefined) dto.mobile = data.mobile;
|
|
if (data.website !== undefined) dto.website = data.website;
|
|
if (data.taxId !== undefined || data.tax_id !== undefined) {
|
|
dto.taxId = data.taxId ?? data.tax_id;
|
|
}
|
|
if (data.taxRegime !== undefined || data.tax_regime !== undefined) {
|
|
dto.taxRegime = data.taxRegime ?? data.tax_regime;
|
|
}
|
|
if (data.cfdiUse !== undefined || data.cfdi_use !== undefined) {
|
|
dto.cfdiUse = data.cfdiUse ?? data.cfdi_use;
|
|
}
|
|
if (data.paymentTermDays !== undefined || data.payment_term_days !== undefined) {
|
|
dto.paymentTermDays = data.paymentTermDays ?? data.payment_term_days;
|
|
}
|
|
if (data.creditLimit !== undefined || data.credit_limit !== undefined) {
|
|
dto.creditLimit = data.creditLimit ?? data.credit_limit;
|
|
}
|
|
if (data.priceListId !== undefined || data.price_list_id !== undefined) {
|
|
dto.priceListId = data.priceListId ?? data.price_list_id;
|
|
}
|
|
if (data.discountPercent !== undefined || data.discount_percent !== undefined) {
|
|
dto.discountPercent = data.discountPercent ?? data.discount_percent;
|
|
}
|
|
if (data.category !== undefined) dto.category = data.category;
|
|
if (data.tags !== undefined) dto.tags = data.tags;
|
|
if (data.notes !== undefined) dto.notes = data.notes;
|
|
if (data.isActive !== undefined || data.is_active !== undefined) {
|
|
dto.isActive = data.isActive ?? data.is_active;
|
|
}
|
|
if (data.isVerified !== undefined || data.is_verified !== undefined) {
|
|
dto.isVerified = data.isVerified ?? data.is_verified;
|
|
}
|
|
if (data.salesRepId !== undefined || data.sales_rep_id !== undefined) {
|
|
dto.salesRepId = data.salesRepId ?? data.sales_rep_id;
|
|
}
|
|
|
|
const partner = await partnersService.update(id, dto, tenantId, userId);
|
|
|
|
const response: ApiResponse = {
|
|
success: true,
|
|
data: partner,
|
|
message: 'Contacto actualizado exitosamente',
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
|
try {
|
|
const { id } = req.params;
|
|
const tenantId = req.user!.tenantId;
|
|
const userId = req.user!.userId;
|
|
|
|
await partnersService.delete(id, tenantId, userId);
|
|
|
|
const response: ApiResponse = {
|
|
success: true,
|
|
message: 'Contacto eliminado exitosamente',
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const partnersController = new PartnersController();
|