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:
Adrian Flores Cortes 2026-01-30 17:39:51 -06:00
parent 6d84520811
commit 6a71183121
13 changed files with 2838 additions and 0 deletions

View 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;

View File

@ -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';

View 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;

View 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;

View 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;
}
}

View File

@ -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';

View 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;
}
}

View 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;
}
}

View File

@ -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';

View File

@ -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;

View File

@ -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;

View 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;

View File

@ -5,3 +5,4 @@
export * from './inspection.controller';
export * from './ticket.controller';
export { default as checklistController } from './checklist.controller';