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:
parent
b927bafeb0
commit
89948663e9
34
src/main.ts
34
src/main.ts
@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
188
src/modules/assets/controllers/asset-assignment.controller.ts
Normal file
188
src/modules/assets/controllers/asset-assignment.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
177
src/modules/assets/controllers/asset-audit.controller.ts
Normal file
177
src/modules/assets/controllers/asset-audit.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
189
src/modules/assets/controllers/asset-maintenance.controller.ts
Normal file
189
src/modules/assets/controllers/asset-maintenance.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
281
src/modules/assets/controllers/asset.controller.ts
Normal file
281
src/modules/assets/controllers/asset.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
9
src/modules/assets/controllers/index.ts
Normal file
9
src/modules/assets/controllers/index.ts
Normal 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';
|
||||||
97
src/modules/assets/entities/asset-assignment.entity.ts
Normal file
97
src/modules/assets/entities/asset-assignment.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
76
src/modules/assets/entities/asset-audit-item.entity.ts
Normal file
76
src/modules/assets/entities/asset-audit-item.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
91
src/modules/assets/entities/asset-audit.entity.ts
Normal file
91
src/modules/assets/entities/asset-audit.entity.ts
Normal 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[];
|
||||||
|
}
|
||||||
62
src/modules/assets/entities/asset-category.entity.ts
Normal file
62
src/modules/assets/entities/asset-category.entity.ts
Normal 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[];
|
||||||
|
}
|
||||||
104
src/modules/assets/entities/asset-maintenance.entity.ts
Normal file
104
src/modules/assets/entities/asset-maintenance.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
163
src/modules/assets/entities/asset.entity.ts
Normal file
163
src/modules/assets/entities/asset.entity.ts
Normal 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[];
|
||||||
|
}
|
||||||
11
src/modules/assets/entities/index.ts
Normal file
11
src/modules/assets/entities/index.ts
Normal 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';
|
||||||
55
src/modules/assets/index.ts
Normal file
55
src/modules/assets/index.ts
Normal 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';
|
||||||
381
src/modules/assets/services/asset-assignment.service.ts
Normal file
381
src/modules/assets/services/asset-assignment.service.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
387
src/modules/assets/services/asset-audit.service.ts
Normal file
387
src/modules/assets/services/asset-audit.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
364
src/modules/assets/services/asset-maintenance.service.ts
Normal file
364
src/modules/assets/services/asset-maintenance.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
483
src/modules/assets/services/asset.service.ts
Normal file
483
src/modules/assets/services/asset.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/assets/services/index.ts
Normal file
9
src/modules/assets/services/index.ts
Normal 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';
|
||||||
Loading…
Reference in New Issue
Block a user