feat(MAI-002,MAI-004,MAI-009): Add missing controllers and services
- construction/torre: Service + Controller for vertical buildings - construction/nivel: Service + Controller for floor levels - construction/departamento: Service + Controller for units - purchase/purchase-order-construction: Controller for PO extensions - purchase/supplier-construction: Controller for supplier extensions - quality/checklist: Controller for inspection templates All endpoints follow existing patterns with multi-tenancy support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d84520811
commit
6a71183121
360
src/modules/construction/controllers/departamento.controller.ts
Normal file
360
src/modules/construction/controllers/departamento.controller.ts
Normal file
@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Departamento Controller
|
||||
* API endpoints para gestión de departamentos/unidades
|
||||
*
|
||||
* @module Construction (MAI-002)
|
||||
* @prefix /api/v1/departamentos
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DepartamentoService, CreateDepartamentoDto, UpdateDepartamentoDto, SellDepartamentoDto } from '../services/departamento.service';
|
||||
|
||||
const router = Router();
|
||||
const departamentoService = new DepartamentoService(null as any);
|
||||
|
||||
/**
|
||||
* GET /api/v1/departamentos
|
||||
* Lista todos los departamentos del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { nivelId, torreId, prototipoId, status, minPrice, maxPrice, search, page, limit } = req.query;
|
||||
|
||||
const result = await departamentoService.findAll(
|
||||
{ tenantId },
|
||||
{
|
||||
nivelId: nivelId as string,
|
||||
torreId: torreId as string,
|
||||
prototipoId: prototipoId as string,
|
||||
status: status as string,
|
||||
minPrice: minPrice ? Number(minPrice) : undefined,
|
||||
maxPrice: maxPrice ? Number(maxPrice) : undefined,
|
||||
search: search as string,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/departamentos/statistics
|
||||
* Estadísticas de departamentos
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { torreId } = req.query;
|
||||
const stats = await departamentoService.getStatistics({ tenantId }, torreId as string);
|
||||
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/departamentos/available
|
||||
* Departamentos disponibles
|
||||
*/
|
||||
router.get('/available', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { torreId } = req.query;
|
||||
const data = await departamentoService.findAvailable({ tenantId }, torreId as string);
|
||||
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/departamentos/by-nivel/:nivelId
|
||||
* Departamentos por nivel
|
||||
*/
|
||||
router.get('/by-nivel/:nivelId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await departamentoService.findByNivel({ tenantId }, req.params.nivelId);
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/departamentos/by-torre/:torreId
|
||||
* Departamentos por torre
|
||||
*/
|
||||
router.get('/by-torre/:torreId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await departamentoService.findByTorre({ tenantId }, req.params.torreId);
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/departamentos/:id
|
||||
* Obtiene un departamento por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const departamento = await departamentoService.findById({ tenantId }, req.params.id);
|
||||
if (!departamento) {
|
||||
return res.status(404).json({ error: 'Departamento no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: departamento });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/departamentos
|
||||
* Crea un nuevo departamento
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateDepartamentoDto = req.body;
|
||||
|
||||
if (!dto.nivelId || !dto.code || !dto.unitNumber) {
|
||||
return res.status(400).json({ error: 'nivelId, code y unitNumber son requeridos' });
|
||||
}
|
||||
|
||||
const departamento = await departamentoService.create({ tenantId, userId }, dto);
|
||||
return res.status(201).json({ success: true, data: departamento });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exists')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/departamentos/:id
|
||||
* Actualiza un departamento
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateDepartamentoDto = req.body;
|
||||
|
||||
const departamento = await departamentoService.update({ tenantId, userId }, req.params.id, dto);
|
||||
if (!departamento) {
|
||||
return res.status(404).json({ error: 'Departamento no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: departamento });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot update')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/departamentos/:id/reserve
|
||||
* Reserva un departamento
|
||||
*/
|
||||
router.post('/:id/reserve', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { buyerId } = req.body;
|
||||
|
||||
if (!buyerId) {
|
||||
return res.status(400).json({ error: 'buyerId es requerido' });
|
||||
}
|
||||
|
||||
const departamento = await departamentoService.reserve({ tenantId, userId }, req.params.id, buyerId);
|
||||
if (!departamento) {
|
||||
return res.status(404).json({ error: 'Departamento no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: departamento, message: 'Departamento reservado' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('not available')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/departamentos/:id/cancel-reservation
|
||||
* Cancela la reservación de un departamento
|
||||
*/
|
||||
router.post('/:id/cancel-reservation', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const departamento = await departamentoService.cancelReservation({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!departamento) {
|
||||
return res.status(404).json({ error: 'Departamento no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: departamento, message: 'Reservación cancelada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('not reserved')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/departamentos/:id/sell
|
||||
* Vende un departamento
|
||||
*/
|
||||
router.post('/:id/sell', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: SellDepartamentoDto = req.body;
|
||||
|
||||
if (!dto.buyerId || dto.priceFinal === undefined) {
|
||||
return res.status(400).json({ error: 'buyerId y priceFinal son requeridos' });
|
||||
}
|
||||
|
||||
const departamento = await departamentoService.sell({ tenantId, userId }, req.params.id, dto);
|
||||
if (!departamento) {
|
||||
return res.status(404).json({ error: 'Departamento no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: departamento, message: 'Departamento vendido' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('not available')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/departamentos/:id/deliver
|
||||
* Marca un departamento como entregado
|
||||
*/
|
||||
router.post('/:id/deliver', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { deliveryDate } = req.body;
|
||||
|
||||
const departamento = await departamentoService.markDelivered(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
deliveryDate ? new Date(deliveryDate) : undefined,
|
||||
);
|
||||
|
||||
if (!departamento) {
|
||||
return res.status(404).json({ error: 'Departamento no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: departamento, message: 'Departamento entregado' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('must be sold')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/departamentos/:id
|
||||
* Elimina un departamento (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await departamentoService.softDelete({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Departamento no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Departamento eliminado' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot delete')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -9,3 +9,6 @@ export { createEtapaController } from './etapa.controller';
|
||||
export { createManzanaController } from './manzana.controller';
|
||||
export { createLoteController } from './lote.controller';
|
||||
export { createPrototipoController } from './prototipo.controller';
|
||||
export { default as torreController } from './torre.controller';
|
||||
export { default as nivelController } from './nivel.controller';
|
||||
export { default as departamentoController } from './departamento.controller';
|
||||
|
||||
245
src/modules/construction/controllers/nivel.controller.ts
Normal file
245
src/modules/construction/controllers/nivel.controller.ts
Normal file
@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Nivel Controller
|
||||
* API endpoints para gestión de niveles/pisos
|
||||
*
|
||||
* @module Construction (MAI-002)
|
||||
* @prefix /api/v1/niveles
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { NivelService, CreateNivelDto, UpdateNivelDto } from '../services/nivel.service';
|
||||
|
||||
const router = Router();
|
||||
const nivelService = new NivelService(null as any);
|
||||
|
||||
/**
|
||||
* GET /api/v1/niveles
|
||||
* Lista todos los niveles del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { torreId, search, page, limit } = req.query;
|
||||
|
||||
const result = await nivelService.findAll(
|
||||
{ tenantId },
|
||||
{
|
||||
torreId: torreId as string,
|
||||
search: search as string,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/niveles/statistics
|
||||
* Estadísticas de niveles
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { torreId } = req.query;
|
||||
const stats = await nivelService.getStatistics({ tenantId }, torreId as string);
|
||||
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/niveles/by-torre/:torreId
|
||||
* Niveles por torre
|
||||
*/
|
||||
router.get('/by-torre/:torreId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await nivelService.findByTorre({ tenantId }, req.params.torreId);
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/niveles/:id
|
||||
* Obtiene un nivel por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const nivel = await nivelService.findById({ tenantId }, req.params.id);
|
||||
if (!nivel) {
|
||||
return res.status(404).json({ error: 'Nivel no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: nivel });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/niveles
|
||||
* Crea un nuevo nivel
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateNivelDto = req.body;
|
||||
|
||||
if (!dto.torreId || dto.floorNumber === undefined) {
|
||||
return res.status(400).json({ error: 'torreId y floorNumber son requeridos' });
|
||||
}
|
||||
|
||||
const nivel = await nivelService.create({ tenantId, userId }, dto);
|
||||
return res.status(201).json({ success: true, data: nivel });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exists')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/niveles/bulk
|
||||
* Crea múltiples niveles para una torre
|
||||
*/
|
||||
router.post('/bulk', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { torreId, totalFloors } = req.body;
|
||||
|
||||
if (!torreId || !totalFloors) {
|
||||
return res.status(400).json({ error: 'torreId y totalFloors son requeridos' });
|
||||
}
|
||||
|
||||
if (totalFloors < 1 || totalFloors > 100) {
|
||||
return res.status(400).json({ error: 'totalFloors debe estar entre 1 y 100' });
|
||||
}
|
||||
|
||||
const niveles = await nivelService.createBulk({ tenantId, userId }, torreId, Number(totalFloors));
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: niveles,
|
||||
count: niveles.length,
|
||||
message: `${niveles.length} niveles creados`,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/niveles/:id
|
||||
* Actualiza un nivel
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateNivelDto = req.body;
|
||||
|
||||
const nivel = await nivelService.update({ tenantId, userId }, req.params.id, dto);
|
||||
if (!nivel) {
|
||||
return res.status(404).json({ error: 'Nivel no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: nivel });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/niveles/:id/recalculate-units
|
||||
* Recalcula el total de unidades de un nivel
|
||||
*/
|
||||
router.post('/:id/recalculate-units', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const nivel = await nivelService.updateUnitCount({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!nivel) {
|
||||
return res.status(404).json({ error: 'Nivel no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: nivel, message: 'Unidades recalculadas' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/niveles/:id
|
||||
* Elimina un nivel (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await nivelService.softDelete({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Nivel no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Nivel eliminado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
241
src/modules/construction/controllers/torre.controller.ts
Normal file
241
src/modules/construction/controllers/torre.controller.ts
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Torre Controller
|
||||
* API endpoints para gestión de torres/edificios
|
||||
*
|
||||
* @module Construction (MAI-002)
|
||||
* @prefix /api/v1/torres
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { TorreService, CreateTorreDto, UpdateTorreDto } from '../services/torre.service';
|
||||
|
||||
const router = Router();
|
||||
const torreService = new TorreService(null as any);
|
||||
|
||||
/**
|
||||
* GET /api/v1/torres
|
||||
* Lista todas las torres del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { etapaId, status, search, page, limit } = req.query;
|
||||
|
||||
const result = await torreService.findAll(
|
||||
{ tenantId },
|
||||
{
|
||||
etapaId: etapaId as string,
|
||||
status: status as string,
|
||||
search: search as string,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/torres/statistics
|
||||
* Estadísticas de torres
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { etapaId } = req.query;
|
||||
const stats = await torreService.getStatistics({ tenantId }, etapaId as string);
|
||||
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/torres/by-etapa/:etapaId
|
||||
* Torres por etapa
|
||||
*/
|
||||
router.get('/by-etapa/:etapaId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await torreService.findByEtapa({ tenantId }, req.params.etapaId);
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/torres/:id
|
||||
* Obtiene una torre por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const torre = await torreService.findById({ tenantId }, req.params.id);
|
||||
if (!torre) {
|
||||
return res.status(404).json({ error: 'Torre no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: torre });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/torres
|
||||
* Crea una nueva torre
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateTorreDto = req.body;
|
||||
|
||||
if (!dto.etapaId || !dto.code || !dto.name) {
|
||||
return res.status(400).json({ error: 'etapaId, code y name son requeridos' });
|
||||
}
|
||||
|
||||
const torre = await torreService.create({ tenantId, userId }, dto);
|
||||
return res.status(201).json({ success: true, data: torre });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exists')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/torres/:id
|
||||
* Actualiza una torre
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateTorreDto = req.body;
|
||||
|
||||
const torre = await torreService.update({ tenantId, userId }, req.params.id, dto);
|
||||
if (!torre) {
|
||||
return res.status(404).json({ error: 'Torre no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: torre });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/torres/:id/status
|
||||
* Actualiza el estado de una torre
|
||||
*/
|
||||
router.patch('/:id/status', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status) {
|
||||
return res.status(400).json({ error: 'status es requerido' });
|
||||
}
|
||||
|
||||
const torre = await torreService.updateStatus({ tenantId, userId }, req.params.id, status);
|
||||
if (!torre) {
|
||||
return res.status(404).json({ error: 'Torre no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: torre, message: 'Estado actualizado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/torres/:id/recalculate-units
|
||||
* Recalcula el total de unidades de una torre
|
||||
*/
|
||||
router.post('/:id/recalculate-units', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const torre = await torreService.updateUnitCount({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!torre) {
|
||||
return res.status(404).json({ error: 'Torre no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: torre, message: 'Unidades recalculadas' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/torres/:id
|
||||
* Elimina una torre (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await torreService.softDelete({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Torre no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Torre eliminada' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
361
src/modules/construction/services/departamento.service.ts
Normal file
361
src/modules/construction/services/departamento.service.ts
Normal file
@ -0,0 +1,361 @@
|
||||
/**
|
||||
* DepartamentoService - Servicio de departamentos/unidades
|
||||
*
|
||||
* Gestión de departamentos/unidades dentro de niveles de construcción vertical.
|
||||
*
|
||||
* @module Construction (MAI-002)
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { Departamento } from '../entities/departamento.entity';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CreateDepartamentoDto {
|
||||
nivelId: string;
|
||||
prototipoId?: string;
|
||||
code: string;
|
||||
unitNumber: string;
|
||||
areaM2?: number;
|
||||
status?: string;
|
||||
priceBase?: number;
|
||||
priceFinal?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDepartamentoDto {
|
||||
prototipoId?: string;
|
||||
unitNumber?: string;
|
||||
areaM2?: number;
|
||||
status?: string;
|
||||
priceBase?: number;
|
||||
priceFinal?: number;
|
||||
}
|
||||
|
||||
export interface SellDepartamentoDto {
|
||||
buyerId: string;
|
||||
priceFinal: number;
|
||||
saleDate?: Date;
|
||||
deliveryDate?: Date;
|
||||
}
|
||||
|
||||
export interface DepartamentoFilters {
|
||||
nivelId?: string;
|
||||
torreId?: string;
|
||||
prototipoId?: string;
|
||||
status?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class DepartamentoService {
|
||||
constructor(
|
||||
private readonly repository: Repository<Departamento>,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: DepartamentoFilters = {},
|
||||
): Promise<{ data: Departamento[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('d')
|
||||
.leftJoinAndSelect('d.nivel', 'nivel')
|
||||
.leftJoinAndSelect('nivel.torre', 'torre')
|
||||
.leftJoinAndSelect('d.prototipo', 'prototipo')
|
||||
.where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('d.deleted_at IS NULL');
|
||||
|
||||
if (filters.nivelId) {
|
||||
queryBuilder.andWhere('d.nivel_id = :nivelId', { nivelId: filters.nivelId });
|
||||
}
|
||||
|
||||
if (filters.torreId) {
|
||||
queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId: filters.torreId });
|
||||
}
|
||||
|
||||
if (filters.prototipoId) {
|
||||
queryBuilder.andWhere('d.prototipo_id = :prototipoId', { prototipoId: filters.prototipoId });
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('d.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.minPrice !== undefined) {
|
||||
queryBuilder.andWhere('d.price_final >= :minPrice', { minPrice: filters.minPrice });
|
||||
}
|
||||
|
||||
if (filters.maxPrice !== undefined) {
|
||||
queryBuilder.andWhere('d.price_final <= :maxPrice', { maxPrice: filters.maxPrice });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(d.code ILIKE :search OR d.unit_number ILIKE :search)',
|
||||
{ search: `%${filters.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('d.code', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Departamento | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
relations: ['nivel', 'nivel.torre', 'prototipo'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(ctx: ServiceContext, nivelId: string, code: string): Promise<Departamento | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
nivelId,
|
||||
code,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByNivel(ctx: ServiceContext, nivelId: string): Promise<Departamento[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('d')
|
||||
.leftJoinAndSelect('d.prototipo', 'prototipo')
|
||||
.where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('d.deleted_at IS NULL')
|
||||
.andWhere('d.nivel_id = :nivelId', { nivelId })
|
||||
.orderBy('d.code', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findByTorre(ctx: ServiceContext, torreId: string): Promise<Departamento[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('d')
|
||||
.leftJoinAndSelect('d.nivel', 'nivel')
|
||||
.leftJoinAndSelect('d.prototipo', 'prototipo')
|
||||
.where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('d.deleted_at IS NULL')
|
||||
.andWhere('nivel.torre_id = :torreId', { torreId })
|
||||
.orderBy('nivel.floor_number', 'ASC')
|
||||
.addOrderBy('d.code', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findAvailable(ctx: ServiceContext, torreId?: string): Promise<Departamento[]> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('d')
|
||||
.leftJoinAndSelect('d.nivel', 'nivel')
|
||||
.leftJoinAndSelect('nivel.torre', 'torre')
|
||||
.leftJoinAndSelect('d.prototipo', 'prototipo')
|
||||
.where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('d.deleted_at IS NULL')
|
||||
.andWhere('d.status = :status', { status: 'available' });
|
||||
|
||||
if (torreId) {
|
||||
queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId });
|
||||
}
|
||||
|
||||
return queryBuilder
|
||||
.orderBy('torre.code', 'ASC')
|
||||
.addOrderBy('nivel.floor_number', 'ASC')
|
||||
.addOrderBy('d.code', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateDepartamentoDto): Promise<Departamento> {
|
||||
const existing = await this.findByCode(ctx, dto.nivelId, dto.code);
|
||||
if (existing) {
|
||||
throw new Error(`Departamento with code ${dto.code} already exists in this nivel`);
|
||||
}
|
||||
|
||||
const departamento = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.userId,
|
||||
nivelId: dto.nivelId,
|
||||
prototipoId: dto.prototipoId,
|
||||
code: dto.code,
|
||||
unitNumber: dto.unitNumber,
|
||||
areaM2: dto.areaM2,
|
||||
status: dto.status ?? 'available',
|
||||
priceBase: dto.priceBase,
|
||||
priceFinal: dto.priceFinal ?? dto.priceBase,
|
||||
});
|
||||
|
||||
return this.repository.save(departamento);
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateDepartamentoDto): Promise<Departamento | null> {
|
||||
const departamento = await this.findById(ctx, id);
|
||||
if (!departamento) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (departamento.status === 'sold' || departamento.status === 'delivered') {
|
||||
throw new Error('Cannot update a sold or delivered unit');
|
||||
}
|
||||
|
||||
Object.assign(departamento, {
|
||||
...dto,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.repository.save(departamento);
|
||||
}
|
||||
|
||||
async sell(ctx: ServiceContext, id: string, dto: SellDepartamentoDto): Promise<Departamento | null> {
|
||||
const departamento = await this.findById(ctx, id);
|
||||
if (!departamento) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (departamento.status !== 'available' && departamento.status !== 'reserved') {
|
||||
throw new Error('Unit is not available for sale');
|
||||
}
|
||||
|
||||
departamento.buyerId = dto.buyerId;
|
||||
departamento.priceFinal = dto.priceFinal;
|
||||
departamento.saleDate = dto.saleDate ?? new Date();
|
||||
if (dto.deliveryDate) {
|
||||
departamento.deliveryDate = dto.deliveryDate;
|
||||
}
|
||||
departamento.status = 'sold';
|
||||
if (ctx.userId) {
|
||||
departamento.updatedBy = ctx.userId;
|
||||
}
|
||||
|
||||
return this.repository.save(departamento);
|
||||
}
|
||||
|
||||
async reserve(ctx: ServiceContext, id: string, buyerId: string): Promise<Departamento | null> {
|
||||
const departamento = await this.findById(ctx, id);
|
||||
if (!departamento) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (departamento.status !== 'available') {
|
||||
throw new Error('Unit is not available for reservation');
|
||||
}
|
||||
|
||||
departamento.buyerId = buyerId;
|
||||
departamento.status = 'reserved';
|
||||
if (ctx.userId) {
|
||||
departamento.updatedBy = ctx.userId;
|
||||
}
|
||||
|
||||
return this.repository.save(departamento);
|
||||
}
|
||||
|
||||
async cancelReservation(ctx: ServiceContext, id: string): Promise<Departamento | null> {
|
||||
const departamento = await this.findById(ctx, id);
|
||||
if (!departamento) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (departamento.status !== 'reserved') {
|
||||
throw new Error('Unit is not reserved');
|
||||
}
|
||||
|
||||
departamento.buyerId = null as any;
|
||||
departamento.status = 'available';
|
||||
if (ctx.userId) {
|
||||
departamento.updatedBy = ctx.userId;
|
||||
}
|
||||
|
||||
return this.repository.save(departamento);
|
||||
}
|
||||
|
||||
async markDelivered(ctx: ServiceContext, id: string, deliveryDate?: Date): Promise<Departamento | null> {
|
||||
const departamento = await this.findById(ctx, id);
|
||||
if (!departamento) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (departamento.status !== 'sold') {
|
||||
throw new Error('Unit must be sold before delivery');
|
||||
}
|
||||
|
||||
departamento.deliveryDate = deliveryDate ?? new Date();
|
||||
departamento.status = 'delivered';
|
||||
if (ctx.userId) {
|
||||
departamento.updatedBy = ctx.userId;
|
||||
}
|
||||
|
||||
return this.repository.save(departamento);
|
||||
}
|
||||
|
||||
async getStatistics(ctx: ServiceContext, torreId?: string): Promise<{
|
||||
totalUnits: number;
|
||||
available: number;
|
||||
reserved: number;
|
||||
sold: number;
|
||||
delivered: number;
|
||||
totalValue: number;
|
||||
soldValue: number;
|
||||
averagePrice: number;
|
||||
}> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('d')
|
||||
.leftJoin('d.nivel', 'nivel')
|
||||
.where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('d.deleted_at IS NULL');
|
||||
|
||||
if (torreId) {
|
||||
queryBuilder.andWhere('nivel.torre_id = :torreId', { torreId });
|
||||
}
|
||||
|
||||
const departamentos = await queryBuilder.getMany();
|
||||
|
||||
const available = departamentos.filter(d => d.status === 'available');
|
||||
const reserved = departamentos.filter(d => d.status === 'reserved');
|
||||
const sold = departamentos.filter(d => d.status === 'sold');
|
||||
const delivered = departamentos.filter(d => d.status === 'delivered');
|
||||
|
||||
const totalValue = departamentos.reduce((sum, d) => sum + Number(d.priceFinal || 0), 0);
|
||||
const soldValue = [...sold, ...delivered].reduce((sum, d) => sum + Number(d.priceFinal || 0), 0);
|
||||
|
||||
return {
|
||||
totalUnits: departamentos.length,
|
||||
available: available.length,
|
||||
reserved: reserved.length,
|
||||
sold: sold.length,
|
||||
delivered: delivered.length,
|
||||
totalValue,
|
||||
soldValue,
|
||||
averagePrice: departamentos.length > 0 ? totalValue / departamentos.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const departamento = await this.findById(ctx, id);
|
||||
if (!departamento) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (departamento.status === 'sold' || departamento.status === 'delivered') {
|
||||
throw new Error('Cannot delete a sold or delivered unit');
|
||||
}
|
||||
|
||||
const result = await this.repository.update(
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ deletedAt: new Date(), deletedBy: ctx.userId },
|
||||
);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@ -9,3 +9,6 @@ export * from './etapa.service';
|
||||
export * from './manzana.service';
|
||||
export * from './lote.service';
|
||||
export * from './prototipo.service';
|
||||
export * from './torre.service';
|
||||
export * from './nivel.service';
|
||||
export * from './departamento.service';
|
||||
|
||||
213
src/modules/construction/services/nivel.service.ts
Normal file
213
src/modules/construction/services/nivel.service.ts
Normal file
@ -0,0 +1,213 @@
|
||||
/**
|
||||
* NivelService - Servicio de niveles/pisos
|
||||
*
|
||||
* Gestión de niveles/pisos dentro de torres de construcción.
|
||||
*
|
||||
* @module Construction (MAI-002)
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { Nivel } from '../entities/nivel.entity';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CreateNivelDto {
|
||||
torreId: string;
|
||||
floorNumber: number;
|
||||
name?: string;
|
||||
totalUnits?: number;
|
||||
}
|
||||
|
||||
export interface UpdateNivelDto {
|
||||
name?: string;
|
||||
totalUnits?: number;
|
||||
}
|
||||
|
||||
export interface NivelFilters {
|
||||
torreId?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class NivelService {
|
||||
constructor(
|
||||
private readonly repository: Repository<Nivel>,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: NivelFilters = {},
|
||||
): Promise<{ data: Nivel[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('n')
|
||||
.leftJoinAndSelect('n.torre', 'torre')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('n.deleted_at IS NULL');
|
||||
|
||||
if (filters.torreId) {
|
||||
queryBuilder.andWhere('n.torre_id = :torreId', { torreId: filters.torreId });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere('n.name ILIKE :search', { search: `%${filters.search}%` });
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('n.floor_number', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Nivel | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
relations: ['torre', 'departamentos'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByFloorNumber(ctx: ServiceContext, torreId: string, floorNumber: number): Promise<Nivel | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
torreId,
|
||||
floorNumber,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByTorre(ctx: ServiceContext, torreId: string): Promise<Nivel[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('n')
|
||||
.leftJoinAndSelect('n.departamentos', 'departamentos', 'departamentos.deleted_at IS NULL')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('n.deleted_at IS NULL')
|
||||
.andWhere('n.torre_id = :torreId', { torreId })
|
||||
.orderBy('n.floor_number', 'ASC')
|
||||
.addOrderBy('departamentos.code', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateNivelDto): Promise<Nivel> {
|
||||
const existing = await this.findByFloorNumber(ctx, dto.torreId, dto.floorNumber);
|
||||
if (existing) {
|
||||
throw new Error(`Floor ${dto.floorNumber} already exists in this torre`);
|
||||
}
|
||||
|
||||
const nivel = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.userId,
|
||||
torreId: dto.torreId,
|
||||
floorNumber: dto.floorNumber,
|
||||
name: dto.name || `Nivel ${dto.floorNumber}`,
|
||||
totalUnits: dto.totalUnits ?? 0,
|
||||
});
|
||||
|
||||
return this.repository.save(nivel);
|
||||
}
|
||||
|
||||
async createBulk(ctx: ServiceContext, torreId: string, totalFloors: number): Promise<Nivel[]> {
|
||||
const niveles: Nivel[] = [];
|
||||
|
||||
for (let floor = 1; floor <= totalFloors; floor++) {
|
||||
const existing = await this.findByFloorNumber(ctx, torreId, floor);
|
||||
if (!existing) {
|
||||
const nivel = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.userId,
|
||||
torreId,
|
||||
floorNumber: floor,
|
||||
name: `Nivel ${floor}`,
|
||||
totalUnits: 0,
|
||||
});
|
||||
niveles.push(nivel);
|
||||
}
|
||||
}
|
||||
|
||||
if (niveles.length > 0) {
|
||||
return this.repository.save(niveles);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateNivelDto): Promise<Nivel | null> {
|
||||
const nivel = await this.findById(ctx, id);
|
||||
if (!nivel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(nivel, {
|
||||
...dto,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.repository.save(nivel);
|
||||
}
|
||||
|
||||
async updateUnitCount(ctx: ServiceContext, id: string): Promise<Nivel | null> {
|
||||
const nivel = await this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId },
|
||||
relations: ['departamentos'],
|
||||
});
|
||||
|
||||
if (!nivel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeUnits = nivel.departamentos?.filter(d => !d.deletedAt) || [];
|
||||
nivel.totalUnits = activeUnits.length;
|
||||
|
||||
if (ctx.userId) {
|
||||
nivel.updatedBy = ctx.userId;
|
||||
}
|
||||
|
||||
return this.repository.save(nivel);
|
||||
}
|
||||
|
||||
async getStatistics(ctx: ServiceContext, torreId?: string): Promise<{
|
||||
totalNiveles: number;
|
||||
totalUnits: number;
|
||||
averageUnitsPerFloor: number;
|
||||
}> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('n')
|
||||
.where('n.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('n.deleted_at IS NULL');
|
||||
|
||||
if (torreId) {
|
||||
queryBuilder.andWhere('n.torre_id = :torreId', { torreId });
|
||||
}
|
||||
|
||||
const niveles = await queryBuilder.getMany();
|
||||
|
||||
const totalUnits = niveles.reduce((sum, n) => sum + n.totalUnits, 0);
|
||||
|
||||
return {
|
||||
totalNiveles: niveles.length,
|
||||
totalUnits,
|
||||
averageUnitsPerFloor: niveles.length > 0 ? totalUnits / niveles.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ deletedAt: new Date(), deletedBy: ctx.userId },
|
||||
);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
226
src/modules/construction/services/torre.service.ts
Normal file
226
src/modules/construction/services/torre.service.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* TorreService - Servicio de torres/edificios
|
||||
*
|
||||
* Gestión de torres/edificios verticales dentro de etapas de construcción.
|
||||
*
|
||||
* @module Construction (MAI-002)
|
||||
*/
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { Torre } from '../entities/torre.entity';
|
||||
|
||||
interface ServiceContext {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface CreateTorreDto {
|
||||
etapaId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
totalFloors?: number;
|
||||
totalUnits?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTorreDto {
|
||||
name?: string;
|
||||
totalFloors?: number;
|
||||
totalUnits?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface TorreFilters {
|
||||
etapaId?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class TorreService {
|
||||
constructor(
|
||||
private readonly repository: Repository<Torre>,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
ctx: ServiceContext,
|
||||
filters: TorreFilters = {},
|
||||
): Promise<{ data: Torre[]; total: number; page: number; limit: number }> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('t')
|
||||
.leftJoinAndSelect('t.etapa', 'etapa')
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.deleted_at IS NULL');
|
||||
|
||||
if (filters.etapaId) {
|
||||
queryBuilder.andWhere('t.etapa_id = :etapaId', { etapaId: filters.etapaId });
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('t.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(t.name ILIKE :search OR t.code ILIKE :search)',
|
||||
{ search: `%${filters.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('t.code', 'ASC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(ctx: ServiceContext, id: string): Promise<Torre | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
relations: ['etapa', 'niveles'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(ctx: ServiceContext, etapaId: string, code: string): Promise<Torre | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
etapaId,
|
||||
code,
|
||||
tenantId: ctx.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByEtapa(ctx: ServiceContext, etapaId: string): Promise<Torre[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('t')
|
||||
.leftJoinAndSelect('t.niveles', 'niveles', 'niveles.deleted_at IS NULL')
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.deleted_at IS NULL')
|
||||
.andWhere('t.etapa_id = :etapaId', { etapaId })
|
||||
.orderBy('t.code', 'ASC')
|
||||
.addOrderBy('niveles.floor_number', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async create(ctx: ServiceContext, dto: CreateTorreDto): Promise<Torre> {
|
||||
const existing = await this.findByCode(ctx, dto.etapaId, dto.code);
|
||||
if (existing) {
|
||||
throw new Error(`Torre with code ${dto.code} already exists in this etapa`);
|
||||
}
|
||||
|
||||
const torre = this.repository.create({
|
||||
tenantId: ctx.tenantId,
|
||||
createdBy: ctx.userId,
|
||||
etapaId: dto.etapaId,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
totalFloors: dto.totalFloors ?? 1,
|
||||
totalUnits: dto.totalUnits ?? 0,
|
||||
status: dto.status ?? 'draft',
|
||||
});
|
||||
|
||||
return this.repository.save(torre);
|
||||
}
|
||||
|
||||
async update(ctx: ServiceContext, id: string, dto: UpdateTorreDto): Promise<Torre | null> {
|
||||
const torre = await this.findById(ctx, id);
|
||||
if (!torre) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Object.assign(torre, {
|
||||
...dto,
|
||||
updatedBy: ctx.userId,
|
||||
});
|
||||
|
||||
return this.repository.save(torre);
|
||||
}
|
||||
|
||||
async updateStatus(ctx: ServiceContext, id: string, status: string): Promise<Torre | null> {
|
||||
const torre = await this.findById(ctx, id);
|
||||
if (!torre) {
|
||||
return null;
|
||||
}
|
||||
|
||||
torre.status = status;
|
||||
if (ctx.userId) {
|
||||
torre.updatedBy = ctx.userId;
|
||||
}
|
||||
|
||||
return this.repository.save(torre);
|
||||
}
|
||||
|
||||
async updateUnitCount(ctx: ServiceContext, id: string): Promise<Torre | null> {
|
||||
const torre = await this.repository.findOne({
|
||||
where: { id, tenantId: ctx.tenantId },
|
||||
relations: ['niveles'],
|
||||
});
|
||||
|
||||
if (!torre) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalUnits = torre.niveles?.reduce((sum, nivel) => sum + nivel.totalUnits, 0) || 0;
|
||||
torre.totalUnits = totalUnits;
|
||||
|
||||
if (ctx.userId) {
|
||||
torre.updatedBy = ctx.userId;
|
||||
}
|
||||
|
||||
return this.repository.save(torre);
|
||||
}
|
||||
|
||||
async getStatistics(ctx: ServiceContext, etapaId?: string): Promise<{
|
||||
totalTorres: number;
|
||||
byStatus: Record<string, number>;
|
||||
totalFloors: number;
|
||||
totalUnits: number;
|
||||
}> {
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('t')
|
||||
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||
.andWhere('t.deleted_at IS NULL');
|
||||
|
||||
if (etapaId) {
|
||||
queryBuilder.andWhere('t.etapa_id = :etapaId', { etapaId });
|
||||
}
|
||||
|
||||
const torres = await queryBuilder.getMany();
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
let totalFloors = 0;
|
||||
let totalUnits = 0;
|
||||
|
||||
for (const torre of torres) {
|
||||
byStatus[torre.status] = (byStatus[torre.status] || 0) + 1;
|
||||
totalFloors += torre.totalFloors;
|
||||
totalUnits += torre.totalUnits;
|
||||
}
|
||||
|
||||
return {
|
||||
totalTorres: torres.length,
|
||||
byStatus,
|
||||
totalFloors,
|
||||
totalUnits,
|
||||
};
|
||||
}
|
||||
|
||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, tenantId: ctx.tenantId },
|
||||
{ deletedAt: new Date(), deletedBy: ctx.userId },
|
||||
);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@ -4,3 +4,5 @@
|
||||
*/
|
||||
|
||||
export * from './comparativo.controller';
|
||||
export { default as purchaseOrderConstructionController } from './purchase-order-construction.controller';
|
||||
export { default as supplierConstructionController } from './supplier-construction.controller';
|
||||
|
||||
@ -0,0 +1,327 @@
|
||||
/**
|
||||
* PurchaseOrderConstruction Controller
|
||||
* API endpoints para extensión de órdenes de compra de construcción
|
||||
*
|
||||
* @module Purchase (MAI-004)
|
||||
* @prefix /api/v1/purchase-orders-construction
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
PurchaseOrderConstructionService,
|
||||
CreatePurchaseOrderConstructionDto,
|
||||
UpdatePurchaseOrderConstructionDto,
|
||||
RegisterReceptionDto,
|
||||
} from '../services/purchase-order-construction.service';
|
||||
|
||||
const router = Router();
|
||||
const purchaseOrderConstructionService = new PurchaseOrderConstructionService(null as any);
|
||||
|
||||
/**
|
||||
* GET /api/v1/purchase-orders-construction
|
||||
* Lista todas las extensiones de OC del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { fraccionamientoId, requisicionId, qualityApproved, receivedFrom, receivedTo, page, limit } = req.query;
|
||||
|
||||
const result = await purchaseOrderConstructionService.findAll(
|
||||
{ tenantId },
|
||||
{
|
||||
fraccionamientoId: fraccionamientoId as string,
|
||||
requisicionId: requisicionId as string,
|
||||
qualityApproved: qualityApproved !== undefined ? qualityApproved === 'true' : undefined,
|
||||
receivedFrom: receivedFrom ? new Date(receivedFrom as string) : undefined,
|
||||
receivedTo: receivedTo ? new Date(receivedTo as string) : undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/purchase-orders-construction/statistics
|
||||
* Estadísticas de recepción de OC
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { fraccionamientoId, dateFrom, dateTo } = req.query;
|
||||
|
||||
const stats = await purchaseOrderConstructionService.getReceptionStatistics(
|
||||
{ tenantId },
|
||||
fraccionamientoId as string,
|
||||
dateFrom ? new Date(dateFrom as string) : undefined,
|
||||
dateTo ? new Date(dateTo as string) : undefined,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/purchase-orders-construction/pending-reception
|
||||
* OC pendientes de recepción
|
||||
*/
|
||||
router.get('/pending-reception', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { fraccionamientoId } = req.query;
|
||||
const data = await purchaseOrderConstructionService.findPendingReception(
|
||||
{ tenantId },
|
||||
fraccionamientoId as string,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/purchase-orders-construction/pending-quality
|
||||
* OC pendientes de aprobación de calidad
|
||||
*/
|
||||
router.get('/pending-quality', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await purchaseOrderConstructionService.findPendingQualityApproval({ tenantId });
|
||||
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/purchase-orders-construction/by-po/:purchaseOrderId
|
||||
* Obtiene extensión por ID de orden de compra original
|
||||
*/
|
||||
router.get('/by-po/:purchaseOrderId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const extension = await purchaseOrderConstructionService.findByPurchaseOrderId(
|
||||
{ tenantId },
|
||||
req.params.purchaseOrderId,
|
||||
);
|
||||
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de OC no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/purchase-orders-construction/:id
|
||||
* Obtiene una extensión de OC por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const extension = await purchaseOrderConstructionService.findById({ tenantId }, req.params.id);
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de OC no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/purchase-orders-construction
|
||||
* Crea una nueva extensión de OC
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreatePurchaseOrderConstructionDto = req.body;
|
||||
|
||||
if (!dto.purchaseOrderId) {
|
||||
return res.status(400).json({ error: 'purchaseOrderId es requerido' });
|
||||
}
|
||||
|
||||
const extension = await purchaseOrderConstructionService.create({ tenantId, userId }, dto);
|
||||
return res.status(201).json({ success: true, data: extension });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exists')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/purchase-orders-construction/:id
|
||||
* Actualiza una extensión de OC
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdatePurchaseOrderConstructionDto = req.body;
|
||||
|
||||
const extension = await purchaseOrderConstructionService.update({ tenantId, userId }, req.params.id, dto);
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de OC no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Cannot update')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/purchase-orders-construction/:id/reception
|
||||
* Registra la recepción de una OC
|
||||
*/
|
||||
router.post('/:id/reception', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: RegisterReceptionDto = {
|
||||
...req.body,
|
||||
receivedById: req.body.receivedById || userId,
|
||||
};
|
||||
|
||||
if (!dto.receivedById) {
|
||||
return res.status(400).json({ error: 'receivedById es requerido' });
|
||||
}
|
||||
|
||||
const extension = await purchaseOrderConstructionService.registerReception({ tenantId, userId }, req.params.id, dto);
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de OC no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension, message: 'Recepción registrada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already been received')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/purchase-orders-construction/:id/quality
|
||||
* Actualiza el estado de calidad de una OC
|
||||
*/
|
||||
router.patch('/:id/quality', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { approved, notes } = req.body;
|
||||
|
||||
if (approved === undefined) {
|
||||
return res.status(400).json({ error: 'approved es requerido' });
|
||||
}
|
||||
|
||||
const extension = await purchaseOrderConstructionService.updateQualityStatus(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
approved,
|
||||
notes,
|
||||
);
|
||||
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de OC no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension, message: 'Estado de calidad actualizado' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('before reception')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/purchase-orders-construction/:id
|
||||
* Elimina una extensión de OC (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await purchaseOrderConstructionService.softDelete({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Extensión de OC no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Extensión de OC eliminada' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -0,0 +1,418 @@
|
||||
/**
|
||||
* SupplierConstruction Controller
|
||||
* API endpoints para extensión de proveedores de construcción
|
||||
*
|
||||
* @module Purchase (MAI-004)
|
||||
* @prefix /api/v1/suppliers-construction
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
SupplierConstructionService,
|
||||
CreateSupplierConstructionDto,
|
||||
UpdateSupplierConstructionDto,
|
||||
EvaluateSupplierDto,
|
||||
} from '../services/supplier-construction.service';
|
||||
|
||||
const router = Router();
|
||||
const supplierConstructionService = new SupplierConstructionService(null as any);
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction
|
||||
* Lista todas las extensiones de proveedores del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { isMaterialsSupplier, isServicesSupplier, isEquipmentSupplier, minRating, hasValidDocuments, specialty, page, limit } = req.query;
|
||||
|
||||
const result = await supplierConstructionService.findAll(
|
||||
{ tenantId },
|
||||
{
|
||||
isMaterialsSupplier: isMaterialsSupplier !== undefined ? isMaterialsSupplier === 'true' : undefined,
|
||||
isServicesSupplier: isServicesSupplier !== undefined ? isServicesSupplier === 'true' : undefined,
|
||||
isEquipmentSupplier: isEquipmentSupplier !== undefined ? isEquipmentSupplier === 'true' : undefined,
|
||||
minRating: minRating ? Number(minRating) : undefined,
|
||||
hasValidDocuments: hasValidDocuments !== undefined ? hasValidDocuments === 'true' : undefined,
|
||||
specialty: specialty as string,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/statistics
|
||||
* Estadísticas de proveedores
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const stats = await supplierConstructionService.getSupplierStatistics({ tenantId });
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/top-rated
|
||||
* Proveedores mejor calificados
|
||||
*/
|
||||
router.get('/top-rated', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { limit } = req.query;
|
||||
const data = await supplierConstructionService.findTopRated({ tenantId }, limit ? Number(limit) : 10);
|
||||
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/by-specialty/:specialty
|
||||
* Proveedores por especialidad
|
||||
*/
|
||||
router.get('/by-specialty/:specialty', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await supplierConstructionService.findBySpecialty({ tenantId }, req.params.specialty);
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/materials
|
||||
* Proveedores de materiales
|
||||
*/
|
||||
router.get('/materials', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await supplierConstructionService.findMaterialsSuppliers({ tenantId });
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/services
|
||||
* Proveedores de servicios
|
||||
*/
|
||||
router.get('/services', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await supplierConstructionService.findServicesSuppliers({ tenantId });
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/equipment
|
||||
* Proveedores de equipos
|
||||
*/
|
||||
router.get('/equipment', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const data = await supplierConstructionService.findEquipmentSuppliers({ tenantId });
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/expiring-documents
|
||||
* Proveedores con documentos por vencer
|
||||
*/
|
||||
router.get('/expiring-documents', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { daysAhead } = req.query;
|
||||
const data = await supplierConstructionService.findWithExpiringDocuments(
|
||||
{ tenantId },
|
||||
daysAhead ? Number(daysAhead) : 30,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/by-supplier/:supplierId
|
||||
* Obtiene extensión por ID de proveedor original
|
||||
*/
|
||||
router.get('/by-supplier/:supplierId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const extension = await supplierConstructionService.findBySupplierId({ tenantId }, req.params.supplierId);
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de proveedor no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/suppliers-construction/:id
|
||||
* Obtiene una extensión de proveedor por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const extension = await supplierConstructionService.findById({ tenantId }, req.params.id);
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de proveedor no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/suppliers-construction
|
||||
* Crea una nueva extensión de proveedor
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateSupplierConstructionDto = req.body;
|
||||
|
||||
if (!dto.supplierId) {
|
||||
return res.status(400).json({ error: 'supplierId es requerido' });
|
||||
}
|
||||
|
||||
const extension = await supplierConstructionService.create({ tenantId, userId }, dto);
|
||||
return res.status(201).json({ success: true, data: extension });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exists')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/suppliers-construction/:id
|
||||
* Actualiza una extensión de proveedor
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateSupplierConstructionDto = req.body;
|
||||
|
||||
const extension = await supplierConstructionService.update({ tenantId, userId }, req.params.id, dto);
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de proveedor no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/suppliers-construction/:id/evaluate
|
||||
* Evalúa un proveedor
|
||||
*/
|
||||
router.post('/:id/evaluate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: EvaluateSupplierDto = req.body;
|
||||
|
||||
if (dto.qualityRating === undefined || dto.deliveryRating === undefined || dto.priceRating === undefined) {
|
||||
return res.status(400).json({ error: 'qualityRating, deliveryRating y priceRating son requeridos' });
|
||||
}
|
||||
|
||||
const extension = await supplierConstructionService.evaluate({ tenantId, userId }, req.params.id, dto);
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de proveedor no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension, message: 'Evaluación registrada' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('between')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/suppliers-construction/:id/documents
|
||||
* Actualiza el estado de documentos de un proveedor
|
||||
*/
|
||||
router.patch('/:id/documents', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { hasValidDocuments, expiryDate } = req.body;
|
||||
|
||||
if (hasValidDocuments === undefined) {
|
||||
return res.status(400).json({ error: 'hasValidDocuments es requerido' });
|
||||
}
|
||||
|
||||
const extension = await supplierConstructionService.updateDocumentStatus(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
hasValidDocuments,
|
||||
expiryDate ? new Date(expiryDate) : undefined,
|
||||
);
|
||||
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de proveedor no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension, message: 'Estado de documentos actualizado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/suppliers-construction/:id/credit
|
||||
* Actualiza los términos de crédito de un proveedor
|
||||
*/
|
||||
router.patch('/:id/credit', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { creditLimit, paymentDays } = req.body;
|
||||
|
||||
if (creditLimit === undefined || paymentDays === undefined) {
|
||||
return res.status(400).json({ error: 'creditLimit y paymentDays son requeridos' });
|
||||
}
|
||||
|
||||
const extension = await supplierConstructionService.updateCreditTerms(
|
||||
{ tenantId, userId },
|
||||
req.params.id,
|
||||
Number(creditLimit),
|
||||
Number(paymentDays),
|
||||
);
|
||||
|
||||
if (!extension) {
|
||||
return res.status(404).json({ error: 'Extensión de proveedor no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: extension, message: 'Términos de crédito actualizados' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('cannot be negative')) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/suppliers-construction/:id
|
||||
* Elimina una extensión de proveedor (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await supplierConstructionService.softDelete({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Extensión de proveedor no encontrada' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Extensión de proveedor eliminada' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
438
src/modules/quality/controllers/checklist.controller.ts
Normal file
438
src/modules/quality/controllers/checklist.controller.ts
Normal file
@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Checklist Controller
|
||||
* API endpoints para plantillas de inspección de calidad
|
||||
*
|
||||
* @module Quality (MAI-009)
|
||||
* @prefix /api/v1/checklists
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ChecklistService,
|
||||
CreateChecklistDto,
|
||||
UpdateChecklistDto,
|
||||
CreateChecklistItemDto,
|
||||
UpdateChecklistItemDto,
|
||||
} from '../services/checklist.service';
|
||||
import { ChecklistStage } from '../entities/checklist.entity';
|
||||
|
||||
const router = Router();
|
||||
const checklistService = new ChecklistService(null as any, null as any);
|
||||
|
||||
/**
|
||||
* GET /api/v1/checklists
|
||||
* Lista todos los checklists del tenant
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const { stage, prototypeId, isActive, search, page, limit } = req.query;
|
||||
|
||||
const result = await checklistService.findAll(
|
||||
{ tenantId },
|
||||
{
|
||||
stage: stage as ChecklistStage,
|
||||
prototypeId: prototypeId as string,
|
||||
isActive: isActive !== undefined ? isActive === 'true' : undefined,
|
||||
search: search as string,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/checklists/statistics
|
||||
* Estadísticas de checklists
|
||||
*/
|
||||
router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const stats = await checklistService.getStatistics({ tenantId });
|
||||
return res.json({ success: true, data: stats });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/checklists/by-stage/:stage
|
||||
* Checklists por etapa de construcción
|
||||
*/
|
||||
router.get('/by-stage/:stage', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const stage = req.params.stage as ChecklistStage;
|
||||
const validStages: ChecklistStage[] = [
|
||||
'foundation', 'structure', 'installations',
|
||||
'finishes', 'delivery', 'custom',
|
||||
];
|
||||
|
||||
if (!validStages.includes(stage)) {
|
||||
return res.status(400).json({ error: `Etapa inválida. Válidas: ${validStages.join(', ')}` });
|
||||
}
|
||||
|
||||
const data = await checklistService.findByStage({ tenantId }, stage);
|
||||
return res.json({ success: true, data, count: data.length });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/checklists/by-code/:code
|
||||
* Buscar checklist por código
|
||||
*/
|
||||
router.get('/by-code/:code', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const checklist = await checklistService.findByCode({ tenantId }, req.params.code);
|
||||
if (!checklist) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: checklist });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/checklists/:id
|
||||
* Obtiene un checklist por ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const checklist = await checklistService.findById({ tenantId }, req.params.id);
|
||||
if (!checklist) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: checklist });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/checklists/:id/with-items
|
||||
* Obtiene un checklist con todos sus items
|
||||
*/
|
||||
router.get('/:id/with-items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const checklist = await checklistService.findWithItems({ tenantId }, req.params.id);
|
||||
if (!checklist) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: checklist });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/checklists
|
||||
* Crea un nuevo checklist
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateChecklistDto = req.body;
|
||||
|
||||
if (!dto.code || !dto.name || !dto.stage) {
|
||||
return res.status(400).json({ error: 'code, name y stage son requeridos' });
|
||||
}
|
||||
|
||||
const checklist = await checklistService.create({ tenantId, userId }, dto);
|
||||
return res.status(201).json({ success: true, data: checklist });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exists')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/checklists/:id
|
||||
* Actualiza un checklist
|
||||
*/
|
||||
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateChecklistDto = req.body;
|
||||
|
||||
const checklist = await checklistService.update({ tenantId, userId }, req.params.id, dto);
|
||||
if (!checklist) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: checklist });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/checklists/:id/activate
|
||||
* Activa un checklist
|
||||
*/
|
||||
router.post('/:id/activate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const checklist = await checklistService.activate({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!checklist) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: checklist, message: 'Checklist activado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/checklists/:id/deactivate
|
||||
* Desactiva un checklist
|
||||
*/
|
||||
router.post('/:id/deactivate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const checklist = await checklistService.deactivate({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!checklist) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: checklist, message: 'Checklist desactivado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/checklists/:id/duplicate
|
||||
* Duplica un checklist con nuevo código y nombre
|
||||
*/
|
||||
router.post('/:id/duplicate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { newCode, newName } = req.body;
|
||||
|
||||
if (!newCode || !newName) {
|
||||
return res.status(400).json({ error: 'newCode y newName son requeridos' });
|
||||
}
|
||||
|
||||
const checklist = await checklistService.duplicate({ tenantId, userId }, req.params.id, newCode, newName);
|
||||
if (!checklist) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.status(201).json({ success: true, data: checklist, message: 'Checklist duplicado' });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('already exists')) {
|
||||
return res.status(409).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/checklists/:id
|
||||
* Elimina un checklist (soft delete)
|
||||
*/
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await checklistService.softDelete({ tenantId, userId }, req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Checklist eliminado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ITEMS ENDPOINTS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/v1/checklists/:id/items
|
||||
* Agrega un item al checklist
|
||||
*/
|
||||
router.post('/:id/items', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: CreateChecklistItemDto = req.body;
|
||||
|
||||
if (dto.sequenceNumber === undefined || !dto.category || !dto.description) {
|
||||
return res.status(400).json({ error: 'sequenceNumber, category y description son requeridos' });
|
||||
}
|
||||
|
||||
const item = await checklistService.addItem({ tenantId, userId }, req.params.id, dto);
|
||||
return res.status(201).json({ success: true, data: item });
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('not found')) {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/checklists/:checklistId/items/:itemId
|
||||
* Actualiza un item del checklist
|
||||
*/
|
||||
router.patch('/:checklistId/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const dto: UpdateChecklistItemDto = req.body;
|
||||
|
||||
const item = await checklistService.updateItem({ tenantId, userId }, req.params.itemId, dto);
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Item no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/checklists/:checklistId/items/:itemId
|
||||
* Elimina un item del checklist
|
||||
*/
|
||||
router.delete('/:checklistId/items/:itemId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const deleted = await checklistService.removeItem({ tenantId, userId }, req.params.itemId);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Item no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Item eliminado' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/checklists/:id/items/reorder
|
||||
* Reordena los items de un checklist
|
||||
*/
|
||||
router.post('/:id/items/reorder', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({ error: 'X-Tenant-Id header required' });
|
||||
}
|
||||
|
||||
const userId = (req as any).user?.id;
|
||||
const { itemOrders } = req.body;
|
||||
|
||||
if (!itemOrders || !Array.isArray(itemOrders)) {
|
||||
return res.status(400).json({ error: 'itemOrders es requerido y debe ser un array' });
|
||||
}
|
||||
|
||||
const success = await checklistService.reorderItems({ tenantId, userId }, req.params.id, itemOrders);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Checklist no encontrado' });
|
||||
}
|
||||
|
||||
return res.json({ success: true, message: 'Items reordenados' });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -5,3 +5,4 @@
|
||||
|
||||
export * from './inspection.controller';
|
||||
export * from './ticket.controller';
|
||||
export { default as checklistController } from './checklist.controller';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user