feat(MMD-013): Implement Asset Management module

- 6 entities: Asset, AssetCategory, AssetAssignment, AssetAudit, AssetAuditItem, AssetMaintenance
- 4 services with CRUD, assignment, audit, and maintenance operations
- 4 controllers with REST endpoints
- Integration in main.ts with routes registration
- Multi-tenant support with RLS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 01:44:58 -06:00
parent b927bafeb0
commit 89948663e9
19 changed files with 3161 additions and 0 deletions

View File

@ -30,6 +30,12 @@ import { createGpsPositionController } from './modules/gps/controllers/gps-posit
import { createGeofenceController } from './modules/gps/controllers/geofence.controller'; import { createGeofenceController } from './modules/gps/controllers/geofence.controller';
import { createRouteSegmentController } from './modules/gps/controllers/route-segment.controller'; import { createRouteSegmentController } from './modules/gps/controllers/route-segment.controller';
// Assets Module Controllers
import { createAssetController } from './modules/assets/controllers/asset.controller';
import { createAssetAssignmentController } from './modules/assets/controllers/asset-assignment.controller';
import { createAssetAuditController } from './modules/assets/controllers/asset-audit.controller';
import { createAssetMaintenanceController } from './modules/assets/controllers/asset-maintenance.controller';
// Payment Terminals Module // Payment Terminals Module
import { PaymentTerminalsModule } from './modules/payment-terminals'; import { PaymentTerminalsModule } from './modules/payment-terminals';
@ -70,6 +76,14 @@ import { Geofence } from './modules/gps/entities/geofence.entity';
import { GeofenceEvent } from './modules/gps/entities/geofence-event.entity'; import { GeofenceEvent } from './modules/gps/entities/geofence-event.entity';
import { RouteSegment } from './modules/gps/entities/route-segment.entity'; import { RouteSegment } from './modules/gps/entities/route-segment.entity';
// Entities - Assets
import { Asset } from './modules/assets/entities/asset.entity';
import { AssetCategory } from './modules/assets/entities/asset-category.entity';
import { AssetAssignment } from './modules/assets/entities/asset-assignment.entity';
import { AssetAudit } from './modules/assets/entities/asset-audit.entity';
import { AssetAuditItem } from './modules/assets/entities/asset-audit-item.entity';
import { AssetMaintenance } from './modules/assets/entities/asset-maintenance.entity';
// Load environment variables // Load environment variables
config(); config();
@ -120,6 +134,13 @@ const AppDataSource = new DataSource({
Geofence, Geofence,
GeofenceEvent, GeofenceEvent,
RouteSegment, RouteSegment,
// Assets
Asset,
AssetCategory,
AssetAssignment,
AssetAudit,
AssetAuditItem,
AssetMaintenance,
], ],
synchronize: process.env.NODE_ENV === 'development', synchronize: process.env.NODE_ENV === 'development',
logging: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development',
@ -173,6 +194,13 @@ async function bootstrap() {
app.use('/api/v1/gps/routes', createRouteSegmentController(AppDataSource)); app.use('/api/v1/gps/routes', createRouteSegmentController(AppDataSource));
console.log('📡 GPS module initialized'); console.log('📡 GPS module initialized');
// Assets Module Routes
app.use('/api/v1/assets', createAssetController(AppDataSource));
app.use('/api/v1/assets/assignments', createAssetAssignmentController(AppDataSource));
app.use('/api/v1/assets/audits', createAssetAuditController(AppDataSource));
app.use('/api/v1/assets/maintenance', createAssetMaintenanceController(AppDataSource));
console.log('📦 Assets module initialized');
// Payment Terminals Module // Payment Terminals Module
const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource }); const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource });
app.use('/api/v1', paymentTerminals.router); app.use('/api/v1', paymentTerminals.router);
@ -204,6 +232,12 @@ async function bootstrap() {
geofences: '/api/v1/gps/geofences', geofences: '/api/v1/gps/geofences',
routes: '/api/v1/gps/routes', routes: '/api/v1/gps/routes',
}, },
assets: {
base: '/api/v1/assets',
assignments: '/api/v1/assets/assignments',
audits: '/api/v1/assets/audits',
maintenance: '/api/v1/assets/maintenance',
},
}, },
documentation: '/api/v1/docs', documentation: '/api/v1/docs',
}); });

View File

@ -0,0 +1,188 @@
/**
* AssetAssignment Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for asset assignments.
* Module: MMD-013 Asset Management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { AssetAssignmentService, AssignmentFilters } from '../services/asset-assignment.service';
import { AssignmentStatus } from '../entities/asset-assignment.entity';
import { AssigneeType } from '../entities/asset.entity';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createAssetAssignmentController(dataSource: DataSource): Router {
const router = Router();
const service = new AssetAssignmentService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Assign an asset
* POST /api/assets/assignments
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const assignment = await service.assign(req.tenantId!, {
...req.body,
assignedBy: req.userId || req.body.assignedBy,
});
res.status(201).json(assignment);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List assignments with filters
* GET /api/assets/assignments
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters: AssignmentFilters = {
assetId: req.query.assetId as string,
assigneeId: req.query.assigneeId as string,
assigneeType: req.query.assigneeType as AssigneeType,
status: req.query.status as AssignmentStatus,
incidentId: req.query.incidentId as string,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get overdue assignments
* GET /api/assets/assignments/overdue
*/
router.get('/overdue', async (req: TenantRequest, res: Response) => {
try {
const assignments = await service.findOverdue(req.tenantId!);
res.json(assignments);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Validate required assets for assignee
* POST /api/assets/assignments/validate
*/
router.post('/validate', async (req: TenantRequest, res: Response) => {
try {
const { requiredCategoryIds, assigneeId, assigneeType } = req.body;
const result = await service.validateRequiredAssets(
req.tenantId!,
requiredCategoryIds,
assigneeId,
assigneeType
);
res.json(result);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Get assignment history for an asset
* GET /api/assets/assignments/asset/:assetId/history
*/
router.get('/asset/:assetId/history', async (req: TenantRequest, res: Response) => {
try {
const history = await service.getAssetHistory(req.tenantId!, req.params.assetId);
res.json(history);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get assignment history for an assignee
* GET /api/assets/assignments/assignee/:assigneeId/history
*/
router.get('/assignee/:assigneeId/history', async (req: TenantRequest, res: Response) => {
try {
const assigneeType = req.query.type as AssigneeType || AssigneeType.EMPLOYEE;
const history = await service.getAssigneeHistory(req.tenantId!, req.params.assigneeId, assigneeType);
res.json(history);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get assignment by ID
* GET /api/assets/assignments/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const assignment = await service.findById(req.tenantId!, req.params.id);
if (!assignment) {
return res.status(404).json({ error: 'Assignment not found' });
}
res.json(assignment);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Return an assigned asset
* POST /api/assets/assignments/:id/return
*/
router.post('/:id/return', async (req: TenantRequest, res: Response) => {
try {
const assignment = await service.return(req.tenantId!, req.params.id, {
...req.body,
returnedTo: req.userId || req.body.returnedTo,
});
res.json(assignment);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Transfer an assignment
* POST /api/assets/assignments/:id/transfer
*/
router.post('/:id/transfer', async (req: TenantRequest, res: Response) => {
try {
const assignment = await service.transfer(req.tenantId!, req.params.id, {
...req.body,
transferredBy: req.userId || req.body.transferredBy,
});
res.json(assignment);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,177 @@
/**
* AssetAudit Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for asset audits.
* Module: MMD-013 Asset Management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { AssetAuditService, AuditFilters } from '../services/asset-audit.service';
import { AuditStatus } from '../entities/asset-audit.entity';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createAssetAuditController(dataSource: DataSource): Router {
const router = Router();
const service = new AssetAuditService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Start a new audit
* POST /api/assets/audits
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const audit = await service.startAudit(req.tenantId!, {
...req.body,
auditorId: req.userId || req.body.auditorId,
});
res.status(201).json(audit);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List audits with filters
* GET /api/assets/audits
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters: AuditFilters = {
auditorId: req.query.auditorId as string,
locationId: req.query.locationId as string,
unitId: req.query.unitId as string,
technicianId: req.query.technicianId as string,
status: req.query.status as AuditStatus,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get audit by ID
* GET /api/assets/audits/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const audit = await service.findById(req.tenantId!, req.params.id);
if (!audit) {
return res.status(404).json({ error: 'Audit not found' });
}
res.json(audit);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get audit items
* GET /api/assets/audits/:id/items
*/
router.get('/:id/items', async (req: TenantRequest, res: Response) => {
try {
const items = await service.getAuditItems(req.params.id);
res.json(items);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get audit summary
* GET /api/assets/audits/:id/summary
*/
router.get('/:id/summary', async (req: TenantRequest, res: Response) => {
try {
const summary = await service.getAuditSummary(req.params.id);
res.json(summary);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get audit discrepancies
* GET /api/assets/audits/:id/discrepancies
*/
router.get('/:id/discrepancies', async (req: TenantRequest, res: Response) => {
try {
const discrepancies = await service.getDiscrepancies(req.tenantId!, req.params.id);
res.json(discrepancies);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Scan an asset in an audit
* POST /api/assets/audits/:id/scan
*/
router.post('/:id/scan', async (req: TenantRequest, res: Response) => {
try {
const item = await service.scanAsset(req.tenantId!, req.params.id, {
...req.body,
scannedBy: req.userId || req.body.scannedBy,
});
res.status(201).json(item);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Complete an audit
* POST /api/assets/audits/:id/complete
*/
router.post('/:id/complete', async (req: TenantRequest, res: Response) => {
try {
const audit = await service.completeAudit(req.tenantId!, req.params.id);
res.json(audit);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Cancel an audit
* POST /api/assets/audits/:id/cancel
*/
router.post('/:id/cancel', async (req: TenantRequest, res: Response) => {
try {
const audit = await service.cancelAudit(req.tenantId!, req.params.id, req.body.reason);
res.json(audit);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,189 @@
/**
* AssetMaintenance Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for asset maintenance.
* Module: MMD-013 Asset Management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { AssetMaintenanceService, MaintenanceFilters } from '../services/asset-maintenance.service';
import { MaintenanceType, MaintenanceStatus } from '../entities/asset-maintenance.entity';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createAssetMaintenanceController(dataSource: DataSource): Router {
const router = Router();
const service = new AssetMaintenanceService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Schedule a maintenance
* POST /api/assets/maintenance
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const maintenance = await service.create(req.tenantId!, {
...req.body,
createdBy: req.userId,
});
res.status(201).json(maintenance);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List maintenance records with filters
* GET /api/assets/maintenance
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters: MaintenanceFilters = {
assetId: req.query.assetId as string,
maintenanceType: req.query.type as MaintenanceType,
status: req.query.status as MaintenanceStatus,
startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined,
endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get maintenance statistics
* GET /api/assets/maintenance/stats
*/
router.get('/stats', async (req: TenantRequest, res: Response) => {
try {
const stats = await service.getStats(req.tenantId!);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get upcoming maintenance
* GET /api/assets/maintenance/upcoming
*/
router.get('/upcoming', async (req: TenantRequest, res: Response) => {
try {
const daysAhead = parseInt(req.query.days as string, 10) || 30;
const maintenance = await service.findUpcoming(req.tenantId!, daysAhead);
res.json(maintenance);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get overdue maintenance
* GET /api/assets/maintenance/overdue
*/
router.get('/overdue', async (req: TenantRequest, res: Response) => {
try {
const maintenance = await service.findOverdue(req.tenantId!);
res.json(maintenance);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get maintenance history for an asset
* GET /api/assets/maintenance/asset/:assetId
*/
router.get('/asset/:assetId', async (req: TenantRequest, res: Response) => {
try {
const history = await service.getAssetHistory(req.tenantId!, req.params.assetId);
res.json(history);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get maintenance by ID
* GET /api/assets/maintenance/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const maintenance = await service.findById(req.tenantId!, req.params.id);
if (!maintenance) {
return res.status(404).json({ error: 'Maintenance record not found' });
}
res.json(maintenance);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Start maintenance
* POST /api/assets/maintenance/:id/start
*/
router.post('/:id/start', async (req: TenantRequest, res: Response) => {
try {
const maintenance = await service.startMaintenance(req.tenantId!, req.params.id);
res.json(maintenance);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Complete maintenance
* POST /api/assets/maintenance/:id/complete
*/
router.post('/:id/complete', async (req: TenantRequest, res: Response) => {
try {
const maintenance = await service.complete(req.tenantId!, req.params.id, {
...req.body,
performedBy: req.userId || req.body.performedBy,
});
res.json(maintenance);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Cancel maintenance
* POST /api/assets/maintenance/:id/cancel
*/
router.post('/:id/cancel', async (req: TenantRequest, res: Response) => {
try {
const maintenance = await service.cancel(req.tenantId!, req.params.id, req.body.reason);
res.json(maintenance);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,281 @@
/**
* Asset Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for asset management.
* Module: MMD-013 Asset Management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { AssetService, AssetFilters } from '../services/asset.service';
import { AssetStatus, AssetLocation, CriticalityLevel, AssigneeType } from '../entities/asset.entity';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createAssetController(dataSource: DataSource): Router {
const router = Router();
const service = new AssetService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
// ==================== ASSETS ====================
/**
* Create a new asset
* POST /api/assets
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const asset = await service.create(req.tenantId!, {
...req.body,
createdBy: req.userId,
});
res.status(201).json(asset);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List assets with filters
* GET /api/assets
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters: AssetFilters = {
categoryId: req.query.categoryId as string,
status: req.query.status as AssetStatus,
currentLocation: req.query.location as AssetLocation,
criticality: req.query.criticality as CriticalityLevel,
currentAssigneeId: req.query.assigneeId as string,
currentAssigneeType: req.query.assigneeType as AssigneeType,
requiresCalibration: req.query.requiresCalibration === 'true' ? true : req.query.requiresCalibration === 'false' ? false : undefined,
search: req.query.search as string,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get asset statistics
* GET /api/assets/stats
*/
router.get('/stats', async (req: TenantRequest, res: Response) => {
try {
const stats = await service.getStats(req.tenantId!);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get assets requiring calibration soon
* GET /api/assets/calibration-due
*/
router.get('/calibration-due', async (req: TenantRequest, res: Response) => {
try {
const daysAhead = parseInt(req.query.days as string, 10) || 30;
const assets = await service.findCalibrationDue(req.tenantId!, daysAhead);
res.json(assets);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Find asset by QR code
* GET /api/assets/qr/:qrCode
*/
router.get('/qr/:qrCode', async (req: TenantRequest, res: Response) => {
try {
const asset = await service.findByQrCode(req.tenantId!, req.params.qrCode);
if (!asset) {
return res.status(404).json({ error: 'Asset not found' });
}
res.json(asset);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Find asset by barcode
* GET /api/assets/barcode/:barcode
*/
router.get('/barcode/:barcode', async (req: TenantRequest, res: Response) => {
try {
const asset = await service.findByBarcode(req.tenantId!, req.params.barcode);
if (!asset) {
return res.status(404).json({ error: 'Asset not found' });
}
res.json(asset);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Find asset by code
* GET /api/assets/code/:code
*/
router.get('/code/:code', async (req: TenantRequest, res: Response) => {
try {
const asset = await service.findByCode(req.tenantId!, req.params.code);
if (!asset) {
return res.status(404).json({ error: 'Asset not found' });
}
res.json(asset);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get assets by assignee
* GET /api/assets/assignee/:assigneeId
*/
router.get('/assignee/:assigneeId', async (req: TenantRequest, res: Response) => {
try {
const assigneeType = req.query.type as AssigneeType || AssigneeType.EMPLOYEE;
const assets = await service.findByAssignee(req.tenantId!, req.params.assigneeId, assigneeType);
res.json(assets);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get asset by ID
* GET /api/assets/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const asset = await service.findById(req.tenantId!, req.params.id);
if (!asset) {
return res.status(404).json({ error: 'Asset not found' });
}
res.json(asset);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update asset
* PATCH /api/assets/:id
*/
router.patch('/:id', async (req: TenantRequest, res: Response) => {
try {
const asset = await service.update(req.tenantId!, req.params.id, req.body);
if (!asset) {
return res.status(404).json({ error: 'Asset not found' });
}
res.json(asset);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Retire asset
* DELETE /api/assets/:id
*/
router.delete('/:id', async (req: TenantRequest, res: Response) => {
try {
const success = await service.retire(req.tenantId!, req.params.id);
if (!success) {
return res.status(404).json({ error: 'Asset not found' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
// ==================== CATEGORIES ====================
/**
* Create a category
* POST /api/assets/categories
*/
router.post('/categories', async (req: TenantRequest, res: Response) => {
try {
const category = await service.createCategory(req.tenantId!, req.body);
res.status(201).json(category);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List all categories
* GET /api/assets/categories
*/
router.get('/categories', async (req: TenantRequest, res: Response) => {
try {
const includeInactive = req.query.includeInactive === 'true';
const categories = await service.findAllCategories(req.tenantId!, includeInactive);
res.json(categories);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get category by ID
* GET /api/assets/categories/:id
*/
router.get('/categories/:id', async (req: TenantRequest, res: Response) => {
try {
const category = await service.findCategoryById(req.tenantId!, req.params.id);
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
res.json(category);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update category
* PATCH /api/assets/categories/:id
*/
router.patch('/categories/:id', async (req: TenantRequest, res: Response) => {
try {
const category = await service.updateCategory(req.tenantId!, req.params.id, req.body);
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
res.json(category);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,9 @@
/**
* Assets Module Controllers
* Module: MMD-013 Asset Management
*/
export * from './asset.controller';
export * from './asset-assignment.controller';
export * from './asset-audit.controller';
export * from './asset-maintenance.controller';

View File

@ -0,0 +1,97 @@
/**
* AssetAssignment Entity
* Mecánicas Diesel - ERP Suite
*
* Tracks asset assignments to employees, units, or locations.
* Module: MMD-013 Asset Management
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Asset, AssigneeType } from './asset.entity';
export enum AssignmentStatus {
ACTIVE = 'active',
RETURNED = 'returned',
OVERDUE = 'overdue',
LOST = 'lost',
DAMAGED = 'damaged',
}
@Entity({ name: 'asset_assignments', schema: 'assets' })
@Index('idx_asset_assignments_tenant', ['tenantId'])
@Index('idx_asset_assignments_asset', ['assetId'])
@Index('idx_asset_assignments_assignee', ['assigneeId', 'assigneeType'])
@Index('idx_asset_assignments_status', ['status'])
@Index('idx_asset_assignments_incident', ['incidentId'])
export class AssetAssignment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId: string;
@Column({ name: 'assignee_id', type: 'uuid' })
assigneeId: string;
@Column({ name: 'assignee_type', type: 'varchar', length: 20 })
assigneeType: AssigneeType;
@Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'NOW()' })
assignedAt: Date;
@Column({ name: 'assigned_by', type: 'uuid' })
assignedBy: string;
@Column({ name: 'expected_return_at', type: 'timestamptz', nullable: true })
expectedReturnAt?: Date;
@Column({ name: 'actual_return_at', type: 'timestamptz', nullable: true })
actualReturnAt?: Date;
@Column({ name: 'returned_to', type: 'uuid', nullable: true })
returnedTo?: string;
@Column({
type: 'varchar',
length: 20,
default: AssignmentStatus.ACTIVE,
})
status: AssignmentStatus;
@Column({ name: 'assignment_photo_url', type: 'text', nullable: true })
assignmentPhotoUrl?: string;
@Column({ name: 'return_photo_url', type: 'text', nullable: true })
returnPhotoUrl?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ name: 'signature_url', type: 'text', nullable: true })
signatureUrl?: string;
@Column({ name: 'incident_id', type: 'uuid', nullable: true })
incidentId?: string;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToOne(() => Asset, asset => asset.assignments)
@JoinColumn({ name: 'asset_id' })
asset: Asset;
}

View File

@ -0,0 +1,76 @@
/**
* AssetAuditItem Entity
* Mecánicas Diesel - ERP Suite
*
* Individual items checked during an audit.
* Module: MMD-013 Asset Management
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { AssetAudit } from './asset-audit.entity';
import { Asset } from './asset.entity';
export enum AuditItemStatus {
FOUND = 'found',
MISSING = 'missing',
DAMAGED = 'damaged',
EXTRA = 'extra',
}
export enum ItemCondition {
GOOD = 'good',
FAIR = 'fair',
POOR = 'poor',
}
@Entity({ name: 'asset_audit_items', schema: 'assets' })
@Index('idx_asset_audit_items_audit', ['auditId'])
@Index('idx_asset_audit_items_asset', ['assetId'])
@Index('idx_asset_audit_items_status', ['status'])
export class AssetAuditItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'audit_id', type: 'uuid' })
auditId: string;
@Column({ name: 'asset_id', type: 'uuid', nullable: true })
assetId?: string;
@Column({
type: 'varchar',
length: 20,
})
status: AuditItemStatus;
@Column({ type: 'varchar', length: 10, nullable: true })
condition?: ItemCondition;
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ name: 'photo_url', type: 'text', nullable: true })
photoUrl?: string;
@Column({ name: 'scanned_at', type: 'timestamptz', default: () => 'NOW()' })
scannedAt: Date;
@Column({ name: 'scanned_by', type: 'uuid', nullable: true })
scannedBy?: string;
// Relations
@ManyToOne(() => AssetAudit, audit => audit.items, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'audit_id' })
audit: AssetAudit;
@ManyToOne(() => Asset, { nullable: true })
@JoinColumn({ name: 'asset_id' })
asset?: Asset;
}

View File

@ -0,0 +1,91 @@
/**
* AssetAudit Entity
* Mecánicas Diesel - ERP Suite
*
* Physical inventory audits.
* Module: MMD-013 Asset Management
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { AssetAuditItem } from './asset-audit-item.entity';
export enum AuditStatus {
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
}
@Entity({ name: 'asset_audits', schema: 'assets' })
@Index('idx_asset_audits_tenant', ['tenantId'])
@Index('idx_asset_audits_date', ['auditDate'])
@Index('idx_asset_audits_auditor', ['auditorId'])
@Index('idx_asset_audits_status', ['status'])
@Index('idx_asset_audits_unit', ['unitId'])
@Index('idx_asset_audits_technician', ['technicianId'])
export class AssetAudit {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'audit_date', type: 'date' })
auditDate: Date;
@Column({ name: 'auditor_id', type: 'uuid' })
auditorId: string;
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId?: string;
@Column({ name: 'unit_id', type: 'uuid', nullable: true })
unitId?: string;
@Column({ name: 'technician_id', type: 'uuid', nullable: true })
technicianId?: string;
@Column({
type: 'varchar',
length: 20,
default: AuditStatus.IN_PROGRESS,
})
status: AuditStatus;
@Column({ name: 'total_assets', type: 'integer', default: 0 })
totalAssets: number;
@Column({ name: 'found_assets', type: 'integer', default: 0 })
foundAssets: number;
@Column({ name: 'missing_assets', type: 'integer', default: 0 })
missingAssets: number;
@Column({ name: 'damaged_assets', type: 'integer', default: 0 })
damagedAssets: number;
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ name: 'started_at', type: 'timestamptz', default: () => 'NOW()' })
startedAt: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt?: Date;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@OneToMany(() => AssetAuditItem, item => item.audit)
items: AssetAuditItem[];
}

View File

@ -0,0 +1,62 @@
/**
* AssetCategory Entity
* Mecánicas Diesel - ERP Suite
*
* Hierarchical categorization of assets.
* Module: MMD-013 Asset Management
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
@Entity({ name: 'asset_categories', schema: 'assets' })
@Index('idx_asset_categories_tenant', ['tenantId'])
@Index('idx_asset_categories_parent', ['parentId'])
export class AssetCategory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId?: string;
@Column({ name: 'requires_service_types', type: 'text', array: true, nullable: true })
requiresServiceTypes?: string[];
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => AssetCategory, category => category.children, { nullable: true })
@JoinColumn({ name: 'parent_id' })
parent?: AssetCategory;
@OneToMany(() => AssetCategory, category => category.parent)
children: AssetCategory[];
}

View File

@ -0,0 +1,104 @@
/**
* AssetMaintenance Entity
* Mecánicas Diesel - ERP Suite
*
* Maintenance records including calibration.
* Module: MMD-013 Asset Management
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Asset } from './asset.entity';
export enum MaintenanceType {
PREVENTIVE = 'preventive',
CORRECTIVE = 'corrective',
CALIBRATION = 'calibration',
}
export enum MaintenanceStatus {
SCHEDULED = 'scheduled',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
}
@Entity({ name: 'asset_maintenance', schema: 'assets' })
@Index('idx_asset_maintenance_tenant', ['tenantId'])
@Index('idx_asset_maintenance_asset', ['assetId'])
@Index('idx_asset_maintenance_type', ['maintenanceType'])
@Index('idx_asset_maintenance_status', ['status'])
@Index('idx_asset_maintenance_scheduled', ['scheduledDate'])
export class AssetMaintenance {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId: string;
@Column({
name: 'maintenance_type',
type: 'varchar',
length: 20,
})
maintenanceType: MaintenanceType;
@Column({ name: 'scheduled_date', type: 'date', nullable: true })
scheduledDate?: Date;
@Column({ name: 'performed_date', type: 'date', nullable: true })
performedDate?: Date;
@Column({ name: 'performed_by', type: 'uuid', nullable: true })
performedBy?: string;
@Column({ name: 'external_vendor', type: 'varchar', length: 200, nullable: true })
externalVendor?: string;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
cost?: number;
@Column({ type: 'text' })
description: string;
@Column({ type: 'text', nullable: true })
result?: string;
@Column({ name: 'next_maintenance_date', type: 'date', nullable: true })
nextMaintenanceDate?: Date;
@Column({
type: 'varchar',
length: 20,
default: MaintenanceStatus.SCHEDULED,
})
status: MaintenanceStatus;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
// Relations
@ManyToOne(() => Asset, asset => asset.maintenanceRecords)
@JoinColumn({ name: 'asset_id' })
asset: Asset;
}

View File

@ -0,0 +1,163 @@
/**
* Asset Entity
* Mecánicas Diesel - ERP Suite
*
* Represents trackable assets and tools.
* Module: MMD-013 Asset Management
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { AssetCategory } from './asset-category.entity';
import { AssetAssignment } from './asset-assignment.entity';
import { AssetMaintenance } from './asset-maintenance.entity';
export enum AssetStatus {
AVAILABLE = 'available',
ASSIGNED = 'assigned',
IN_MAINTENANCE = 'in_maintenance',
DAMAGED = 'damaged',
RETIRED = 'retired',
}
export enum AssetLocation {
WAREHOUSE = 'warehouse',
UNIT = 'unit',
TECHNICIAN = 'technician',
EXTERNAL = 'external',
}
export enum CriticalityLevel {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical',
}
export enum AssigneeType {
EMPLOYEE = 'employee',
UNIT = 'unit',
LOCATION = 'location',
}
@Entity({ name: 'assets', schema: 'assets' })
@Index('idx_assets_tenant', ['tenantId'])
@Index('idx_assets_category', ['categoryId'])
@Index('idx_assets_status', ['status'])
@Index('idx_assets_location', ['currentLocation'])
@Index('idx_assets_assignee', ['currentAssigneeId'])
export class Asset {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 50 })
code: string;
@Column({ type: 'varchar', length: 150 })
name: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'category_id', type: 'uuid' })
categoryId: string;
@Column({ name: 'serial_number', type: 'varchar', length: 100, nullable: true })
serialNumber?: string;
@Column({ name: 'qr_code', type: 'varchar', length: 100, nullable: true })
qrCode?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
barcode?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
manufacturer?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
model?: string;
@Column({ name: 'purchase_date', type: 'date', nullable: true })
purchaseDate?: Date;
@Column({ name: 'purchase_cost', type: 'decimal', precision: 12, scale: 2, nullable: true })
purchaseCost?: number;
@Column({ name: 'warranty_expiry', type: 'date', nullable: true })
warrantyExpiry?: Date;
@Column({
type: 'varchar',
length: 20,
default: AssetStatus.AVAILABLE,
})
status: AssetStatus;
@Column({
name: 'current_location',
type: 'varchar',
length: 20,
default: AssetLocation.WAREHOUSE,
})
currentLocation: AssetLocation;
@Column({ name: 'current_assignee_id', type: 'uuid', nullable: true })
currentAssigneeId?: string;
@Column({ name: 'current_assignee_type', type: 'varchar', length: 20, nullable: true })
currentAssigneeType?: AssigneeType;
@Column({
type: 'varchar',
length: 20,
default: CriticalityLevel.MEDIUM,
})
criticality: CriticalityLevel;
@Column({ name: 'requires_calibration', type: 'boolean', default: false })
requiresCalibration: boolean;
@Column({ name: 'last_calibration_date', type: 'date', nullable: true })
lastCalibrationDate?: Date;
@Column({ name: 'next_calibration_date', type: 'date', nullable: true })
nextCalibrationDate?: Date;
@Column({ name: 'photo_url', type: 'text', nullable: true })
photoUrl?: string;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
// Relations
@ManyToOne(() => AssetCategory)
@JoinColumn({ name: 'category_id' })
category: AssetCategory;
@OneToMany(() => AssetAssignment, assignment => assignment.asset)
assignments: AssetAssignment[];
@OneToMany(() => AssetMaintenance, maintenance => maintenance.asset)
maintenanceRecords: AssetMaintenance[];
}

View File

@ -0,0 +1,11 @@
/**
* Assets Module Entities
* Module: MMD-013 Asset Management
*/
export * from './asset-category.entity';
export * from './asset.entity';
export * from './asset-assignment.entity';
export * from './asset-audit.entity';
export * from './asset-audit-item.entity';
export * from './asset-maintenance.entity';

View File

@ -0,0 +1,55 @@
/**
* Assets Module
* Mecánicas Diesel - ERP Suite
* Module: MMD-013 Asset Management
*/
// Entities
export {
Asset,
AssetStatus,
AssetLocation,
CriticalityLevel,
AssigneeType,
} from './entities/asset.entity';
export { AssetCategory } from './entities/asset-category.entity';
export { AssetAssignment, AssignmentStatus } from './entities/asset-assignment.entity';
export { AssetAudit, AuditStatus } from './entities/asset-audit.entity';
export { AssetAuditItem, AuditItemStatus, ItemCondition } from './entities/asset-audit-item.entity';
export { AssetMaintenance, MaintenanceType, MaintenanceStatus } from './entities/asset-maintenance.entity';
// Services
export {
AssetService,
CreateAssetDto,
UpdateAssetDto,
AssetFilters,
CreateCategoryDto,
UpdateCategoryDto,
} from './services/asset.service';
export {
AssetAssignmentService,
AssignAssetDto,
ReturnAssetDto,
TransferAssetDto,
AssignmentFilters,
} from './services/asset-assignment.service';
export {
AssetAuditService,
StartAuditDto,
ScanAssetDto,
AuditFilters,
} from './services/asset-audit.service';
export {
AssetMaintenanceService,
CreateMaintenanceDto,
UpdateMaintenanceDto,
CompleteMaintenanceDto,
MaintenanceFilters,
} from './services/asset-maintenance.service';
// Controllers
export { createAssetController } from './controllers/asset.controller';
export { createAssetAssignmentController } from './controllers/asset-assignment.controller';
export { createAssetAuditController } from './controllers/asset-audit.controller';
export { createAssetMaintenanceController } from './controllers/asset-maintenance.controller';

View File

@ -0,0 +1,381 @@
/**
* AssetAssignment Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for asset assignment/lending.
* Module: MMD-013 Asset Management
*/
import { Repository, DataSource } from 'typeorm';
import { AssetAssignment, AssignmentStatus } from '../entities/asset-assignment.entity';
import { Asset, AssetStatus, AssetLocation, AssigneeType } from '../entities/asset.entity';
// DTOs
export interface AssignAssetDto {
assetId: string;
assigneeId: string;
assigneeType: AssigneeType;
assignedBy: string;
expectedReturnAt?: Date;
assignmentPhotoUrl?: string;
notes?: string;
signatureUrl?: string;
incidentId?: string;
metadata?: Record<string, any>;
}
export interface ReturnAssetDto {
returnedTo: string;
returnPhotoUrl?: string;
notes?: string;
condition?: 'good' | 'damaged' | 'lost';
}
export interface TransferAssetDto {
newAssigneeId: string;
newAssigneeType: AssigneeType;
transferredBy: string;
notes?: string;
}
export interface AssignmentFilters {
assetId?: string;
assigneeId?: string;
assigneeType?: AssigneeType;
status?: AssignmentStatus;
incidentId?: string;
startDate?: Date;
endDate?: Date;
}
export interface ValidationResult {
valid: boolean;
missingAssets: string[];
availableAssets: string[];
}
export class AssetAssignmentService {
private assignmentRepository: Repository<AssetAssignment>;
private assetRepository: Repository<Asset>;
constructor(dataSource: DataSource) {
this.assignmentRepository = dataSource.getRepository(AssetAssignment);
this.assetRepository = dataSource.getRepository(Asset);
}
/**
* Assign an asset to an assignee
*/
async assign(tenantId: string, dto: AssignAssetDto): Promise<AssetAssignment> {
// Check asset exists and is available
const asset = await this.assetRepository.findOne({
where: { id: dto.assetId, tenantId },
});
if (!asset) {
throw new Error(`Asset ${dto.assetId} not found`);
}
if (asset.status !== AssetStatus.AVAILABLE) {
throw new Error(`Asset ${asset.code} is not available (current status: ${asset.status})`);
}
// Create assignment
const assignment = this.assignmentRepository.create({
tenantId,
assetId: dto.assetId,
assigneeId: dto.assigneeId,
assigneeType: dto.assigneeType,
assignedBy: dto.assignedBy,
expectedReturnAt: dto.expectedReturnAt,
assignmentPhotoUrl: dto.assignmentPhotoUrl,
notes: dto.notes,
signatureUrl: dto.signatureUrl,
incidentId: dto.incidentId,
metadata: dto.metadata || {},
status: AssignmentStatus.ACTIVE,
});
const savedAssignment = await this.assignmentRepository.save(assignment);
// Update asset status (trigger should handle this, but we update for safety)
await this.assetRepository.update(
{ id: dto.assetId },
{
status: AssetStatus.ASSIGNED,
currentLocation: dto.assigneeType === AssigneeType.UNIT ? AssetLocation.UNIT : AssetLocation.TECHNICIAN,
currentAssigneeId: dto.assigneeId,
currentAssigneeType: dto.assigneeType,
}
);
return savedAssignment;
}
/**
* Return an assigned asset
*/
async return(tenantId: string, assignmentId: string, dto: ReturnAssetDto): Promise<AssetAssignment> {
const assignment = await this.assignmentRepository.findOne({
where: { id: assignmentId, tenantId },
});
if (!assignment) {
throw new Error(`Assignment ${assignmentId} not found`);
}
if (assignment.status !== AssignmentStatus.ACTIVE) {
throw new Error(`Assignment is not active (current status: ${assignment.status})`);
}
// Determine return status based on condition
let newStatus: AssignmentStatus;
let assetStatus: AssetStatus;
switch (dto.condition) {
case 'damaged':
newStatus = AssignmentStatus.DAMAGED;
assetStatus = AssetStatus.DAMAGED;
break;
case 'lost':
newStatus = AssignmentStatus.LOST;
assetStatus = AssetStatus.RETIRED;
break;
default:
newStatus = AssignmentStatus.RETURNED;
assetStatus = AssetStatus.AVAILABLE;
}
// Update assignment
assignment.status = newStatus;
assignment.actualReturnAt = new Date();
assignment.returnedTo = dto.returnedTo;
assignment.returnPhotoUrl = dto.returnPhotoUrl;
if (dto.notes) {
assignment.notes = assignment.notes ? `${assignment.notes}\n${dto.notes}` : dto.notes;
}
const savedAssignment = await this.assignmentRepository.save(assignment);
// Update asset
await this.assetRepository.update(
{ id: assignment.assetId },
{
status: assetStatus,
currentLocation: AssetLocation.WAREHOUSE,
currentAssigneeId: undefined,
currentAssigneeType: undefined,
}
);
return savedAssignment;
}
/**
* Transfer an asset to a new assignee
*/
async transfer(tenantId: string, assignmentId: string, dto: TransferAssetDto): Promise<AssetAssignment> {
const assignment = await this.assignmentRepository.findOne({
where: { id: assignmentId, tenantId },
});
if (!assignment) {
throw new Error(`Assignment ${assignmentId} not found`);
}
if (assignment.status !== AssignmentStatus.ACTIVE) {
throw new Error(`Assignment is not active (current status: ${assignment.status})`);
}
// Close current assignment
assignment.status = AssignmentStatus.RETURNED;
assignment.actualReturnAt = new Date();
assignment.returnedTo = dto.transferredBy;
assignment.notes = assignment.notes
? `${assignment.notes}\nTransferred to ${dto.newAssigneeId}`
: `Transferred to ${dto.newAssigneeId}`;
await this.assignmentRepository.save(assignment);
// Create new assignment
const newAssignment = this.assignmentRepository.create({
tenantId,
assetId: assignment.assetId,
assigneeId: dto.newAssigneeId,
assigneeType: dto.newAssigneeType,
assignedBy: dto.transferredBy,
notes: dto.notes || `Transferred from ${assignment.assigneeId}`,
incidentId: assignment.incidentId,
status: AssignmentStatus.ACTIVE,
});
const savedAssignment = await this.assignmentRepository.save(newAssignment);
// Update asset
await this.assetRepository.update(
{ id: assignment.assetId },
{
currentLocation: dto.newAssigneeType === AssigneeType.UNIT ? AssetLocation.UNIT : AssetLocation.TECHNICIAN,
currentAssigneeId: dto.newAssigneeId,
currentAssigneeType: dto.newAssigneeType,
}
);
return savedAssignment;
}
/**
* Get assignment by ID
*/
async findById(tenantId: string, id: string): Promise<AssetAssignment | null> {
return this.assignmentRepository.findOne({
where: { id, tenantId },
relations: ['asset'],
});
}
/**
* Get active assignments for an asset
*/
async getActiveAssignments(tenantId: string, assetId: string): Promise<AssetAssignment[]> {
return this.assignmentRepository.find({
where: { tenantId, assetId, status: AssignmentStatus.ACTIVE },
relations: ['asset'],
});
}
/**
* List assignments with filters
*/
async findAll(
tenantId: string,
filters: AssignmentFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.assignmentRepository.createQueryBuilder('assignment')
.leftJoinAndSelect('assignment.asset', 'asset')
.where('assignment.tenant_id = :tenantId', { tenantId });
if (filters.assetId) {
queryBuilder.andWhere('assignment.asset_id = :assetId', { assetId: filters.assetId });
}
if (filters.assigneeId) {
queryBuilder.andWhere('assignment.assignee_id = :assigneeId', { assigneeId: filters.assigneeId });
}
if (filters.assigneeType) {
queryBuilder.andWhere('assignment.assignee_type = :assigneeType', { assigneeType: filters.assigneeType });
}
if (filters.status) {
queryBuilder.andWhere('assignment.status = :status', { status: filters.status });
}
if (filters.incidentId) {
queryBuilder.andWhere('assignment.incident_id = :incidentId', { incidentId: filters.incidentId });
}
if (filters.startDate) {
queryBuilder.andWhere('assignment.assigned_at >= :startDate', { startDate: filters.startDate });
}
if (filters.endDate) {
queryBuilder.andWhere('assignment.assigned_at <= :endDate', { endDate: filters.endDate });
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('assignment.assigned_at', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Get overdue assignments
*/
async findOverdue(tenantId: string): Promise<AssetAssignment[]> {
const now = new Date();
return this.assignmentRepository
.createQueryBuilder('assignment')
.leftJoinAndSelect('assignment.asset', 'asset')
.where('assignment.tenant_id = :tenantId', { tenantId })
.andWhere('assignment.status = :status', { status: AssignmentStatus.ACTIVE })
.andWhere('assignment.expected_return_at IS NOT NULL')
.andWhere('assignment.expected_return_at < :now', { now })
.orderBy('assignment.expected_return_at', 'ASC')
.getMany();
}
/**
* Validate required assets for an incident
*/
async validateRequiredAssets(
tenantId: string,
requiredCategoryIds: string[],
assigneeId: string,
assigneeType: AssigneeType
): Promise<ValidationResult> {
if (requiredCategoryIds.length === 0) {
return { valid: true, missingAssets: [], availableAssets: [] };
}
// Get assets currently assigned to the assignee
const assignedAssets = await this.assetRepository.find({
where: {
tenantId,
currentAssigneeId: assigneeId,
currentAssigneeType: assigneeType,
status: AssetStatus.ASSIGNED,
},
});
const assignedCategoryIds = new Set(assignedAssets.map(a => a.categoryId));
const missingAssets: string[] = [];
const availableAssets: string[] = [];
for (const categoryId of requiredCategoryIds) {
if (assignedCategoryIds.has(categoryId)) {
availableAssets.push(categoryId);
} else {
missingAssets.push(categoryId);
}
}
return {
valid: missingAssets.length === 0,
missingAssets,
availableAssets,
};
}
/**
* Get assignment history for an asset
*/
async getAssetHistory(tenantId: string, assetId: string): Promise<AssetAssignment[]> {
return this.assignmentRepository.find({
where: { tenantId, assetId },
order: { assignedAt: 'DESC' },
});
}
/**
* Get assignment history for an assignee
*/
async getAssigneeHistory(
tenantId: string,
assigneeId: string,
assigneeType: AssigneeType
): Promise<AssetAssignment[]> {
return this.assignmentRepository.find({
where: { tenantId, assigneeId, assigneeType },
relations: ['asset'],
order: { assignedAt: 'DESC' },
});
}
}

View File

@ -0,0 +1,387 @@
/**
* AssetAudit Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for asset inventory audits.
* Module: MMD-013 Asset Management
*/
import { Repository, DataSource } from 'typeorm';
import { AssetAudit, AuditStatus } from '../entities/asset-audit.entity';
import { AssetAuditItem, AuditItemStatus, ItemCondition } from '../entities/asset-audit-item.entity';
import { Asset, AssetStatus, AssigneeType } from '../entities/asset.entity';
// DTOs
export interface StartAuditDto {
auditorId: string;
locationId?: string;
unitId?: string;
technicianId?: string;
notes?: string;
metadata?: Record<string, any>;
}
export interface ScanAssetDto {
assetId?: string;
qrCode?: string;
barcode?: string;
status: AuditItemStatus;
condition?: ItemCondition;
notes?: string;
photoUrl?: string;
scannedBy?: string;
}
export interface AuditFilters {
auditorId?: string;
locationId?: string;
unitId?: string;
technicianId?: string;
status?: AuditStatus;
startDate?: Date;
endDate?: Date;
}
export interface AuditDiscrepancy {
assetId: string;
assetCode: string;
assetName: string;
status: AuditItemStatus;
condition?: ItemCondition;
notes?: string;
}
export class AssetAuditService {
private auditRepository: Repository<AssetAudit>;
private auditItemRepository: Repository<AssetAuditItem>;
private assetRepository: Repository<Asset>;
constructor(dataSource: DataSource) {
this.auditRepository = dataSource.getRepository(AssetAudit);
this.auditItemRepository = dataSource.getRepository(AssetAuditItem);
this.assetRepository = dataSource.getRepository(Asset);
}
/**
* Start a new audit
*/
async startAudit(tenantId: string, dto: StartAuditDto): Promise<AssetAudit> {
// Validate that at least one scope is provided
if (!dto.locationId && !dto.unitId && !dto.technicianId) {
throw new Error('Audit must have a scope: location, unit, or technician');
}
// Check for existing in-progress audit with same scope
const existingAudit = await this.auditRepository.findOne({
where: {
tenantId,
status: AuditStatus.IN_PROGRESS,
locationId: dto.locationId,
unitId: dto.unitId,
technicianId: dto.technicianId,
},
});
if (existingAudit) {
throw new Error('An audit is already in progress for this scope');
}
const audit = this.auditRepository.create({
tenantId,
auditDate: new Date(),
auditorId: dto.auditorId,
locationId: dto.locationId,
unitId: dto.unitId,
technicianId: dto.technicianId,
notes: dto.notes,
metadata: dto.metadata || {},
status: AuditStatus.IN_PROGRESS,
});
return this.auditRepository.save(audit);
}
/**
* Scan/record an asset in an audit
*/
async scanAsset(tenantId: string, auditId: string, dto: ScanAssetDto): Promise<AssetAuditItem> {
const audit = await this.auditRepository.findOne({
where: { id: auditId, tenantId },
});
if (!audit) {
throw new Error(`Audit ${auditId} not found`);
}
if (audit.status !== AuditStatus.IN_PROGRESS) {
throw new Error('Audit is not in progress');
}
// Find the asset
let assetId = dto.assetId;
if (!assetId && dto.qrCode) {
const asset = await this.assetRepository.findOne({
where: { tenantId, qrCode: dto.qrCode },
});
if (asset) assetId = asset.id;
}
if (!assetId && dto.barcode) {
const asset = await this.assetRepository.findOne({
where: { tenantId, barcode: dto.barcode },
});
if (asset) assetId = asset.id;
}
// Check if already scanned
if (assetId) {
const existing = await this.auditItemRepository.findOne({
where: { auditId, assetId },
});
if (existing) {
// Update existing scan
existing.status = dto.status;
existing.condition = dto.condition;
existing.notes = dto.notes;
existing.photoUrl = dto.photoUrl;
existing.scannedAt = new Date();
existing.scannedBy = dto.scannedBy;
return this.auditItemRepository.save(existing);
}
}
// Create new audit item
const auditItem = this.auditItemRepository.create({
auditId,
assetId,
status: dto.status,
condition: dto.condition,
notes: dto.notes,
photoUrl: dto.photoUrl,
scannedBy: dto.scannedBy,
});
return this.auditItemRepository.save(auditItem);
}
/**
* Complete an audit
*/
async completeAudit(tenantId: string, auditId: string): Promise<AssetAudit> {
const audit = await this.auditRepository.findOne({
where: { id: auditId, tenantId },
});
if (!audit) {
throw new Error(`Audit ${auditId} not found`);
}
if (audit.status !== AuditStatus.IN_PROGRESS) {
throw new Error('Audit is not in progress');
}
// Auto-mark missing assets
await this.markMissingAssets(tenantId, audit);
// Update audit status
audit.status = AuditStatus.COMPLETED;
audit.completedAt = new Date();
return this.auditRepository.save(audit);
}
/**
* Cancel an audit
*/
async cancelAudit(tenantId: string, auditId: string, reason?: string): Promise<AssetAudit> {
const audit = await this.auditRepository.findOne({
where: { id: auditId, tenantId },
});
if (!audit) {
throw new Error(`Audit ${auditId} not found`);
}
if (audit.status === AuditStatus.COMPLETED) {
throw new Error('Cannot cancel a completed audit');
}
audit.status = AuditStatus.CANCELLED;
if (reason) {
audit.notes = audit.notes ? `${audit.notes}\nCancellation reason: ${reason}` : `Cancellation reason: ${reason}`;
}
return this.auditRepository.save(audit);
}
/**
* Mark assets not scanned as missing
*/
private async markMissingAssets(tenantId: string, audit: AssetAudit): Promise<void> {
// Get expected assets based on audit scope
let expectedAssets: Asset[] = [];
if (audit.technicianId) {
expectedAssets = await this.assetRepository.find({
where: {
tenantId,
currentAssigneeId: audit.technicianId,
currentAssigneeType: AssigneeType.EMPLOYEE,
status: AssetStatus.ASSIGNED,
},
});
} else if (audit.unitId) {
expectedAssets = await this.assetRepository.find({
where: {
tenantId,
currentAssigneeId: audit.unitId,
currentAssigneeType: AssigneeType.UNIT,
status: AssetStatus.ASSIGNED,
},
});
}
// Get already scanned asset IDs
const scannedItems = await this.auditItemRepository.find({
where: { auditId: audit.id },
});
const scannedAssetIds = new Set(scannedItems.filter(i => i.assetId).map(i => i.assetId));
// Mark missing assets
for (const asset of expectedAssets) {
if (!scannedAssetIds.has(asset.id)) {
const missingItem = this.auditItemRepository.create({
auditId: audit.id,
assetId: asset.id,
status: AuditItemStatus.MISSING,
notes: 'Auto-marked as missing - not scanned during audit',
});
await this.auditItemRepository.save(missingItem);
}
}
}
/**
* Get audit by ID
*/
async findById(tenantId: string, id: string): Promise<AssetAudit | null> {
return this.auditRepository.findOne({
where: { id, tenantId },
relations: ['items'],
});
}
/**
* List audits with filters
*/
async findAll(
tenantId: string,
filters: AuditFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.auditRepository.createQueryBuilder('audit')
.where('audit.tenant_id = :tenantId', { tenantId });
if (filters.auditorId) {
queryBuilder.andWhere('audit.auditor_id = :auditorId', { auditorId: filters.auditorId });
}
if (filters.locationId) {
queryBuilder.andWhere('audit.location_id = :locationId', { locationId: filters.locationId });
}
if (filters.unitId) {
queryBuilder.andWhere('audit.unit_id = :unitId', { unitId: filters.unitId });
}
if (filters.technicianId) {
queryBuilder.andWhere('audit.technician_id = :technicianId', { technicianId: filters.technicianId });
}
if (filters.status) {
queryBuilder.andWhere('audit.status = :status', { status: filters.status });
}
if (filters.startDate) {
queryBuilder.andWhere('audit.audit_date >= :startDate', { startDate: filters.startDate });
}
if (filters.endDate) {
queryBuilder.andWhere('audit.audit_date <= :endDate', { endDate: filters.endDate });
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('audit.audit_date', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Get audit items for an audit
*/
async getAuditItems(auditId: string): Promise<AssetAuditItem[]> {
return this.auditItemRepository.find({
where: { auditId },
relations: ['asset'],
order: { scannedAt: 'DESC' },
});
}
/**
* Get discrepancies (missing, damaged, extra)
*/
async getDiscrepancies(tenantId: string, auditId: string): Promise<AuditDiscrepancy[]> {
const items = await this.auditItemRepository
.createQueryBuilder('item')
.leftJoinAndSelect('item.asset', 'asset')
.where('item.audit_id = :auditId', { auditId })
.andWhere('item.status != :found', { found: AuditItemStatus.FOUND })
.getMany();
return items.map(item => ({
assetId: item.assetId || 'unknown',
assetCode: item.asset?.code || 'unknown',
assetName: item.asset?.name || 'Unknown asset',
status: item.status,
condition: item.condition,
notes: item.notes,
}));
}
/**
* Get audit summary statistics
*/
async getAuditSummary(auditId: string): Promise<{
totalScanned: number;
found: number;
missing: number;
damaged: number;
extra: number;
accuracyRate: number;
}> {
const items = await this.auditItemRepository.find({
where: { auditId },
});
const found = items.filter(i => i.status === AuditItemStatus.FOUND).length;
const missing = items.filter(i => i.status === AuditItemStatus.MISSING).length;
const damaged = items.filter(i => i.status === AuditItemStatus.DAMAGED).length;
const extra = items.filter(i => i.status === AuditItemStatus.EXTRA).length;
const totalScanned = items.length;
const expected = found + missing + damaged; // Excludes extra
const accuracyRate = expected > 0 ? (found / expected) * 100 : 100;
return {
totalScanned,
found,
missing,
damaged,
extra,
accuracyRate: Math.round(accuracyRate * 100) / 100,
};
}
}

View File

@ -0,0 +1,364 @@
/**
* AssetMaintenance Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for asset maintenance tracking.
* Module: MMD-013 Asset Management
*/
import { Repository, DataSource, LessThanOrEqual } from 'typeorm';
import { AssetMaintenance, MaintenanceType, MaintenanceStatus } from '../entities/asset-maintenance.entity';
import { Asset, AssetStatus } from '../entities/asset.entity';
// DTOs
export interface CreateMaintenanceDto {
assetId: string;
maintenanceType: MaintenanceType;
scheduledDate?: Date;
description: string;
externalVendor?: string;
metadata?: Record<string, any>;
createdBy?: string;
}
export interface UpdateMaintenanceDto {
scheduledDate?: Date;
description?: string;
externalVendor?: string;
cost?: number;
result?: string;
nextMaintenanceDate?: Date;
metadata?: Record<string, any>;
}
export interface CompleteMaintenanceDto {
performedBy: string;
cost?: number;
result?: string;
nextMaintenanceDate?: Date;
}
export interface MaintenanceFilters {
assetId?: string;
maintenanceType?: MaintenanceType;
status?: MaintenanceStatus;
startDate?: Date;
endDate?: Date;
}
export class AssetMaintenanceService {
private maintenanceRepository: Repository<AssetMaintenance>;
private assetRepository: Repository<Asset>;
constructor(dataSource: DataSource) {
this.maintenanceRepository = dataSource.getRepository(AssetMaintenance);
this.assetRepository = dataSource.getRepository(Asset);
}
/**
* Schedule a maintenance
*/
async create(tenantId: string, dto: CreateMaintenanceDto): Promise<AssetMaintenance> {
// Validate asset exists
const asset = await this.assetRepository.findOne({
where: { id: dto.assetId, tenantId },
});
if (!asset) {
throw new Error(`Asset ${dto.assetId} not found`);
}
if (asset.status === AssetStatus.RETIRED) {
throw new Error('Cannot schedule maintenance for a retired asset');
}
const maintenance = this.maintenanceRepository.create({
tenantId,
assetId: dto.assetId,
maintenanceType: dto.maintenanceType,
scheduledDate: dto.scheduledDate,
description: dto.description,
externalVendor: dto.externalVendor,
metadata: dto.metadata || {},
createdBy: dto.createdBy,
status: dto.scheduledDate ? MaintenanceStatus.SCHEDULED : MaintenanceStatus.IN_PROGRESS,
});
return this.maintenanceRepository.save(maintenance);
}
/**
* Start maintenance (puts asset in maintenance status)
*/
async startMaintenance(tenantId: string, id: string): Promise<AssetMaintenance> {
const maintenance = await this.maintenanceRepository.findOne({
where: { id, tenantId },
});
if (!maintenance) {
throw new Error(`Maintenance record ${id} not found`);
}
if (maintenance.status !== MaintenanceStatus.SCHEDULED) {
throw new Error(`Cannot start maintenance with status ${maintenance.status}`);
}
// Update maintenance status
maintenance.status = MaintenanceStatus.IN_PROGRESS;
await this.maintenanceRepository.save(maintenance);
// Update asset status
await this.assetRepository.update(
{ id: maintenance.assetId },
{ status: AssetStatus.IN_MAINTENANCE }
);
return maintenance;
}
/**
* Complete a maintenance
*/
async complete(tenantId: string, id: string, dto: CompleteMaintenanceDto): Promise<AssetMaintenance> {
const maintenance = await this.maintenanceRepository.findOne({
where: { id, tenantId },
});
if (!maintenance) {
throw new Error(`Maintenance record ${id} not found`);
}
if (maintenance.status === MaintenanceStatus.COMPLETED || maintenance.status === MaintenanceStatus.CANCELLED) {
throw new Error(`Cannot complete maintenance with status ${maintenance.status}`);
}
// Update maintenance
maintenance.status = MaintenanceStatus.COMPLETED;
maintenance.performedDate = new Date();
maintenance.performedBy = dto.performedBy;
maintenance.cost = dto.cost;
maintenance.result = dto.result;
maintenance.nextMaintenanceDate = dto.nextMaintenanceDate;
await this.maintenanceRepository.save(maintenance);
// Update asset
const assetUpdate: Partial<Asset> = {
status: AssetStatus.AVAILABLE,
};
if (maintenance.maintenanceType === MaintenanceType.CALIBRATION) {
assetUpdate.lastCalibrationDate = new Date();
if (dto.nextMaintenanceDate) {
assetUpdate.nextCalibrationDate = dto.nextMaintenanceDate;
}
}
await this.assetRepository.update({ id: maintenance.assetId }, assetUpdate);
return maintenance;
}
/**
* Cancel a maintenance
*/
async cancel(tenantId: string, id: string, reason?: string): Promise<AssetMaintenance> {
const maintenance = await this.maintenanceRepository.findOne({
where: { id, tenantId },
});
if (!maintenance) {
throw new Error(`Maintenance record ${id} not found`);
}
if (maintenance.status === MaintenanceStatus.COMPLETED) {
throw new Error('Cannot cancel a completed maintenance');
}
maintenance.status = MaintenanceStatus.CANCELLED;
if (reason) {
maintenance.result = reason;
}
// If asset was in maintenance, return to available
const asset = await this.assetRepository.findOne({
where: { id: maintenance.assetId },
});
if (asset && asset.status === AssetStatus.IN_MAINTENANCE) {
await this.assetRepository.update(
{ id: maintenance.assetId },
{ status: AssetStatus.AVAILABLE }
);
}
return this.maintenanceRepository.save(maintenance);
}
/**
* Get maintenance by ID
*/
async findById(tenantId: string, id: string): Promise<AssetMaintenance | null> {
return this.maintenanceRepository.findOne({
where: { id, tenantId },
relations: ['asset'],
});
}
/**
* List maintenance records with filters
*/
async findAll(
tenantId: string,
filters: MaintenanceFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.maintenanceRepository.createQueryBuilder('maintenance')
.leftJoinAndSelect('maintenance.asset', 'asset')
.where('maintenance.tenant_id = :tenantId', { tenantId });
if (filters.assetId) {
queryBuilder.andWhere('maintenance.asset_id = :assetId', { assetId: filters.assetId });
}
if (filters.maintenanceType) {
queryBuilder.andWhere('maintenance.maintenance_type = :maintenanceType', { maintenanceType: filters.maintenanceType });
}
if (filters.status) {
queryBuilder.andWhere('maintenance.status = :status', { status: filters.status });
}
if (filters.startDate) {
queryBuilder.andWhere('maintenance.scheduled_date >= :startDate', { startDate: filters.startDate });
}
if (filters.endDate) {
queryBuilder.andWhere('maintenance.scheduled_date <= :endDate', { endDate: filters.endDate });
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('maintenance.scheduled_date', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Get upcoming scheduled maintenance
*/
async findUpcoming(tenantId: string, daysAhead: number = 30): Promise<AssetMaintenance[]> {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + daysAhead);
return this.maintenanceRepository
.createQueryBuilder('maintenance')
.leftJoinAndSelect('maintenance.asset', 'asset')
.where('maintenance.tenant_id = :tenantId', { tenantId })
.andWhere('maintenance.status = :status', { status: MaintenanceStatus.SCHEDULED })
.andWhere('maintenance.scheduled_date <= :futureDate', { futureDate })
.orderBy('maintenance.scheduled_date', 'ASC')
.getMany();
}
/**
* Get overdue maintenance
*/
async findOverdue(tenantId: string): Promise<AssetMaintenance[]> {
const today = new Date();
return this.maintenanceRepository
.createQueryBuilder('maintenance')
.leftJoinAndSelect('maintenance.asset', 'asset')
.where('maintenance.tenant_id = :tenantId', { tenantId })
.andWhere('maintenance.status = :status', { status: MaintenanceStatus.SCHEDULED })
.andWhere('maintenance.scheduled_date < :today', { today })
.orderBy('maintenance.scheduled_date', 'ASC')
.getMany();
}
/**
* Get maintenance history for an asset
*/
async getAssetHistory(tenantId: string, assetId: string): Promise<AssetMaintenance[]> {
return this.maintenanceRepository.find({
where: { tenantId, assetId },
order: { createdAt: 'DESC' },
});
}
/**
* Get maintenance statistics
*/
async getStats(tenantId: string): Promise<{
scheduled: number;
inProgress: number;
completedThisMonth: number;
overdue: number;
totalCostThisMonth: number;
byType: Record<MaintenanceType, number>;
}> {
const today = new Date();
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const [scheduled, inProgress, completedThisMonth, overdue, typeCounts, costSum] = await Promise.all([
this.maintenanceRepository.count({
where: { tenantId, status: MaintenanceStatus.SCHEDULED },
}),
this.maintenanceRepository.count({
where: { tenantId, status: MaintenanceStatus.IN_PROGRESS },
}),
this.maintenanceRepository
.createQueryBuilder('m')
.where('m.tenant_id = :tenantId', { tenantId })
.andWhere('m.status = :status', { status: MaintenanceStatus.COMPLETED })
.andWhere('m.performed_date >= :startOfMonth', { startOfMonth })
.getCount(),
this.maintenanceRepository
.createQueryBuilder('m')
.where('m.tenant_id = :tenantId', { tenantId })
.andWhere('m.status = :status', { status: MaintenanceStatus.SCHEDULED })
.andWhere('m.scheduled_date < :today', { today })
.getCount(),
this.maintenanceRepository
.createQueryBuilder('m')
.select('m.maintenance_type', 'type')
.addSelect('COUNT(*)', 'count')
.where('m.tenant_id = :tenantId', { tenantId })
.groupBy('m.maintenance_type')
.getRawMany(),
this.maintenanceRepository
.createQueryBuilder('m')
.select('SUM(m.cost)', 'total')
.where('m.tenant_id = :tenantId', { tenantId })
.andWhere('m.status = :status', { status: MaintenanceStatus.COMPLETED })
.andWhere('m.performed_date >= :startOfMonth', { startOfMonth })
.getRawOne(),
]);
const byType: Record<MaintenanceType, number> = {
[MaintenanceType.PREVENTIVE]: 0,
[MaintenanceType.CORRECTIVE]: 0,
[MaintenanceType.CALIBRATION]: 0,
};
for (const row of typeCounts) {
if (row.type) byType[row.type as MaintenanceType] = parseInt(row.count, 10);
}
return {
scheduled,
inProgress,
completedThisMonth,
overdue,
totalCostThisMonth: parseFloat(costSum?.total) || 0,
byType,
};
}
}

View File

@ -0,0 +1,483 @@
/**
* Asset Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for asset management.
* Module: MMD-013 Asset Management
*/
import { Repository, DataSource } from 'typeorm';
import {
Asset,
AssetStatus,
AssetLocation,
CriticalityLevel,
AssigneeType,
} from '../entities/asset.entity';
import { AssetCategory } from '../entities/asset-category.entity';
// DTOs
export interface CreateAssetDto {
code: string;
name: string;
description?: string;
categoryId: string;
serialNumber?: string;
qrCode?: string;
barcode?: string;
manufacturer?: string;
model?: string;
purchaseDate?: Date;
purchaseCost?: number;
warrantyExpiry?: Date;
criticality?: CriticalityLevel;
requiresCalibration?: boolean;
nextCalibrationDate?: Date;
photoUrl?: string;
metadata?: Record<string, any>;
createdBy?: string;
}
export interface UpdateAssetDto {
name?: string;
description?: string;
categoryId?: string;
serialNumber?: string;
qrCode?: string;
barcode?: string;
manufacturer?: string;
model?: string;
purchaseDate?: Date;
purchaseCost?: number;
warrantyExpiry?: Date;
status?: AssetStatus;
currentLocation?: AssetLocation;
criticality?: CriticalityLevel;
requiresCalibration?: boolean;
lastCalibrationDate?: Date;
nextCalibrationDate?: Date;
photoUrl?: string;
metadata?: Record<string, any>;
}
export interface AssetFilters {
categoryId?: string;
status?: AssetStatus;
currentLocation?: AssetLocation;
criticality?: CriticalityLevel;
currentAssigneeId?: string;
currentAssigneeType?: AssigneeType;
requiresCalibration?: boolean;
search?: string;
}
export interface CreateCategoryDto {
name: string;
description?: string;
parentId?: string;
requiresServiceTypes?: string[];
sortOrder?: number;
}
export interface UpdateCategoryDto {
name?: string;
description?: string;
parentId?: string;
requiresServiceTypes?: string[];
sortOrder?: number;
isActive?: boolean;
}
export class AssetService {
private assetRepository: Repository<Asset>;
private categoryRepository: Repository<AssetCategory>;
constructor(dataSource: DataSource) {
this.assetRepository = dataSource.getRepository(Asset);
this.categoryRepository = dataSource.getRepository(AssetCategory);
}
// ==================== ASSETS ====================
/**
* Create a new asset
*/
async create(tenantId: string, dto: CreateAssetDto): Promise<Asset> {
// Check for duplicate code
const existingCode = await this.assetRepository.findOne({
where: { tenantId, code: dto.code },
});
if (existingCode) {
throw new Error(`Asset with code ${dto.code} already exists`);
}
// Check for duplicate QR code if provided
if (dto.qrCode) {
const existingQr = await this.assetRepository.findOne({
where: { tenantId, qrCode: dto.qrCode },
});
if (existingQr) {
throw new Error(`Asset with QR code ${dto.qrCode} already exists`);
}
}
const asset = this.assetRepository.create({
tenantId,
code: dto.code,
name: dto.name,
description: dto.description,
categoryId: dto.categoryId,
serialNumber: dto.serialNumber,
qrCode: dto.qrCode,
barcode: dto.barcode,
manufacturer: dto.manufacturer,
model: dto.model,
purchaseDate: dto.purchaseDate,
purchaseCost: dto.purchaseCost,
warrantyExpiry: dto.warrantyExpiry,
criticality: dto.criticality || CriticalityLevel.MEDIUM,
requiresCalibration: dto.requiresCalibration || false,
nextCalibrationDate: dto.nextCalibrationDate,
photoUrl: dto.photoUrl,
metadata: dto.metadata || {},
createdBy: dto.createdBy,
status: AssetStatus.AVAILABLE,
currentLocation: AssetLocation.WAREHOUSE,
});
return this.assetRepository.save(asset);
}
/**
* Find asset by ID
*/
async findById(tenantId: string, id: string): Promise<Asset | null> {
return this.assetRepository.findOne({
where: { id, tenantId },
relations: ['category'],
});
}
/**
* Find asset by code
*/
async findByCode(tenantId: string, code: string): Promise<Asset | null> {
return this.assetRepository.findOne({
where: { tenantId, code },
relations: ['category'],
});
}
/**
* Find asset by QR code
*/
async findByQrCode(tenantId: string, qrCode: string): Promise<Asset | null> {
return this.assetRepository.findOne({
where: { tenantId, qrCode },
relations: ['category'],
});
}
/**
* Find asset by barcode
*/
async findByBarcode(tenantId: string, barcode: string): Promise<Asset | null> {
return this.assetRepository.findOne({
where: { tenantId, barcode },
relations: ['category'],
});
}
/**
* List assets with filters
*/
async findAll(
tenantId: string,
filters: AssetFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.assetRepository.createQueryBuilder('asset')
.leftJoinAndSelect('asset.category', 'category')
.where('asset.tenant_id = :tenantId', { tenantId });
if (filters.categoryId) {
queryBuilder.andWhere('asset.category_id = :categoryId', { categoryId: filters.categoryId });
}
if (filters.status) {
queryBuilder.andWhere('asset.status = :status', { status: filters.status });
}
if (filters.currentLocation) {
queryBuilder.andWhere('asset.current_location = :currentLocation', { currentLocation: filters.currentLocation });
}
if (filters.criticality) {
queryBuilder.andWhere('asset.criticality = :criticality', { criticality: filters.criticality });
}
if (filters.currentAssigneeId) {
queryBuilder.andWhere('asset.current_assignee_id = :currentAssigneeId', { currentAssigneeId: filters.currentAssigneeId });
}
if (filters.currentAssigneeType) {
queryBuilder.andWhere('asset.current_assignee_type = :currentAssigneeType', { currentAssigneeType: filters.currentAssigneeType });
}
if (filters.requiresCalibration !== undefined) {
queryBuilder.andWhere('asset.requires_calibration = :requiresCalibration', { requiresCalibration: filters.requiresCalibration });
}
if (filters.search) {
queryBuilder.andWhere(
'(asset.code ILIKE :search OR asset.name ILIKE :search OR asset.serial_number ILIKE :search OR asset.qr_code ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('asset.created_at', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update asset
*/
async update(tenantId: string, id: string, dto: UpdateAssetDto): Promise<Asset | null> {
const asset = await this.findById(tenantId, id);
if (!asset) return null;
// Check QR code uniqueness if changing
if (dto.qrCode && dto.qrCode !== asset.qrCode) {
const existing = await this.findByQrCode(tenantId, dto.qrCode);
if (existing) {
throw new Error(`Asset with QR code ${dto.qrCode} already exists`);
}
}
Object.assign(asset, dto);
return this.assetRepository.save(asset);
}
/**
* Retire asset
*/
async retire(tenantId: string, id: string): Promise<boolean> {
const asset = await this.findById(tenantId, id);
if (!asset) return false;
if (asset.status === AssetStatus.ASSIGNED) {
throw new Error('Cannot retire an assigned asset');
}
asset.status = AssetStatus.RETIRED;
await this.assetRepository.save(asset);
return true;
}
/**
* Get assets by assignee
*/
async findByAssignee(tenantId: string, assigneeId: string, assigneeType: AssigneeType): Promise<Asset[]> {
return this.assetRepository.find({
where: {
tenantId,
currentAssigneeId: assigneeId,
currentAssigneeType: assigneeType,
status: AssetStatus.ASSIGNED,
},
relations: ['category'],
order: { name: 'ASC' },
});
}
/**
* Get assets requiring calibration soon
*/
async findCalibrationDue(tenantId: string, daysAhead: number = 30): Promise<Asset[]> {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + daysAhead);
return this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.category', 'category')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.requires_calibration = :requiresCalibration', { requiresCalibration: true })
.andWhere('asset.status != :retired', { retired: AssetStatus.RETIRED })
.andWhere('asset.next_calibration_date <= :futureDate', { futureDate })
.orderBy('asset.next_calibration_date', 'ASC')
.getMany();
}
/**
* Get asset statistics
*/
async getStats(tenantId: string): Promise<{
total: number;
byStatus: Record<AssetStatus, number>;
byLocation: Record<AssetLocation, number>;
byCriticality: Record<CriticalityLevel, number>;
calibrationDue: number;
warrantyExpiring: number;
}> {
const now = new Date();
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
const [total, statusCounts, locationCounts, criticalityCounts, calibrationDue, warrantyExpiring] = await Promise.all([
this.assetRepository.count({ where: { tenantId } }),
this.assetRepository
.createQueryBuilder('asset')
.select('asset.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('asset.tenant_id = :tenantId', { tenantId })
.groupBy('asset.status')
.getRawMany(),
this.assetRepository
.createQueryBuilder('asset')
.select('asset.current_location', 'location')
.addSelect('COUNT(*)', 'count')
.where('asset.tenant_id = :tenantId', { tenantId })
.groupBy('asset.current_location')
.getRawMany(),
this.assetRepository
.createQueryBuilder('asset')
.select('asset.criticality', 'criticality')
.addSelect('COUNT(*)', 'count')
.where('asset.tenant_id = :tenantId', { tenantId })
.groupBy('asset.criticality')
.getRawMany(),
this.assetRepository
.createQueryBuilder('asset')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.requires_calibration = :requiresCalibration', { requiresCalibration: true })
.andWhere('asset.next_calibration_date <= :futureDate', { futureDate: thirtyDaysFromNow })
.andWhere('asset.status != :retired', { retired: AssetStatus.RETIRED })
.getCount(),
this.assetRepository
.createQueryBuilder('asset')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.warranty_expiry <= :futureDate', { futureDate: thirtyDaysFromNow })
.andWhere('asset.warranty_expiry >= :now', { now })
.andWhere('asset.status != :retired', { retired: AssetStatus.RETIRED })
.getCount(),
]);
const byStatus: Record<AssetStatus, number> = {
[AssetStatus.AVAILABLE]: 0,
[AssetStatus.ASSIGNED]: 0,
[AssetStatus.IN_MAINTENANCE]: 0,
[AssetStatus.DAMAGED]: 0,
[AssetStatus.RETIRED]: 0,
};
const byLocation: Record<AssetLocation, number> = {
[AssetLocation.WAREHOUSE]: 0,
[AssetLocation.UNIT]: 0,
[AssetLocation.TECHNICIAN]: 0,
[AssetLocation.EXTERNAL]: 0,
};
const byCriticality: Record<CriticalityLevel, number> = {
[CriticalityLevel.LOW]: 0,
[CriticalityLevel.MEDIUM]: 0,
[CriticalityLevel.HIGH]: 0,
[CriticalityLevel.CRITICAL]: 0,
};
for (const row of statusCounts) {
if (row.status) byStatus[row.status as AssetStatus] = parseInt(row.count, 10);
}
for (const row of locationCounts) {
if (row.location) byLocation[row.location as AssetLocation] = parseInt(row.count, 10);
}
for (const row of criticalityCounts) {
if (row.criticality) byCriticality[row.criticality as CriticalityLevel] = parseInt(row.count, 10);
}
return {
total,
byStatus,
byLocation,
byCriticality,
calibrationDue,
warrantyExpiring,
};
}
// ==================== CATEGORIES ====================
/**
* Create a category
*/
async createCategory(tenantId: string, dto: CreateCategoryDto): Promise<AssetCategory> {
const existing = await this.categoryRepository.findOne({
where: { tenantId, name: dto.name },
});
if (existing) {
throw new Error(`Category ${dto.name} already exists`);
}
const category = this.categoryRepository.create({
tenantId,
name: dto.name,
description: dto.description,
parentId: dto.parentId,
requiresServiceTypes: dto.requiresServiceTypes,
sortOrder: dto.sortOrder || 0,
isActive: true,
});
return this.categoryRepository.save(category);
}
/**
* Get all categories
*/
async findAllCategories(tenantId: string, includeInactive: boolean = false): Promise<AssetCategory[]> {
const where: any = { tenantId };
if (!includeInactive) {
where.isActive = true;
}
return this.categoryRepository.find({
where,
order: { sortOrder: 'ASC', name: 'ASC' },
});
}
/**
* Get category by ID
*/
async findCategoryById(tenantId: string, id: string): Promise<AssetCategory | null> {
return this.categoryRepository.findOne({
where: { id, tenantId },
relations: ['parent', 'children'],
});
}
/**
* Update category
*/
async updateCategory(tenantId: string, id: string, dto: UpdateCategoryDto): Promise<AssetCategory | null> {
const category = await this.findCategoryById(tenantId, id);
if (!category) return null;
if (dto.name && dto.name !== category.name) {
const existing = await this.categoryRepository.findOne({
where: { tenantId, name: dto.name },
});
if (existing) {
throw new Error(`Category ${dto.name} already exists`);
}
}
Object.assign(category, dto);
return this.categoryRepository.save(category);
}
}

View File

@ -0,0 +1,9 @@
/**
* Assets Module Services
* Module: MMD-013 Asset Management
*/
export * from './asset.service';
export * from './asset-assignment.service';
export * from './asset-audit.service';
export * from './asset-maintenance.service';