Initial deploy commit
This commit is contained in:
commit
10448633cf
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
||||
# Mecánicas Diesel Backend - Environment Variables
|
||||
|
||||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3010
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5434
|
||||
DB_NAME=mecanicas_diesel_dev
|
||||
DB_USER=mecanicas_user
|
||||
DB_PASSWORD=mecanicas_secret_2024
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-jwt-secret-change-in-production
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
61
.env.production
Normal file
61
.env.production
Normal file
@ -0,0 +1,61 @@
|
||||
# =============================================================================
|
||||
# ERP-SUITE: MECANICAS-DIESEL Backend - Production Environment
|
||||
# =============================================================================
|
||||
# Servidor: 72.60.226.4
|
||||
# Dominio: api.mecanicas.erp.isem.dev
|
||||
# =============================================================================
|
||||
|
||||
NODE_ENV=production
|
||||
PORT=3041
|
||||
API_PREFIX=api
|
||||
API_VERSION=v1
|
||||
|
||||
SERVER_URL=https://api.mecanicas.erp.isem.dev
|
||||
FRONTEND_URL=https://mecanicas.erp.isem.dev
|
||||
|
||||
# Database
|
||||
DB_HOST=${DB_HOST:-localhost}
|
||||
DB_PORT=5432
|
||||
DB_NAME=erp_generic
|
||||
DB_USER=erp_admin
|
||||
DB_PASSWORD=${DB_PASSWORD}
|
||||
DB_SCHEMA=service_management,parts_management,vehicle_management
|
||||
DB_SSL=true
|
||||
DB_SYNCHRONIZE=false
|
||||
DB_LOGGING=false
|
||||
DB_POOL_MAX=15
|
||||
|
||||
DB_CORE_SCHEMAS=auth,core
|
||||
|
||||
# Redis (compartido)
|
||||
REDIS_HOST=${REDIS_HOST:-localhost}
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
REDIS_DB=2
|
||||
|
||||
# JWT (compartido para SSO)
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Multi-tenant
|
||||
TENANT_HEADER=x-tenant-id
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=https://mecanicas.erp.isem.dev,https://erp.isem.dev
|
||||
|
||||
# Security
|
||||
ENABLE_SWAGGER=false
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
RATE_LIMIT_MAX=100
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=warn
|
||||
LOG_TO_FILE=true
|
||||
LOG_FILE_PATH=/var/log/mecanicas/app.log
|
||||
|
||||
# Features específicas
|
||||
FEATURE_SERVICE_ORDERS=true
|
||||
FEATURE_PARTS_INVENTORY=true
|
||||
FEATURE_VEHICLE_TRACKING=true
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment files (local)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@ -0,0 +1,39 @@
|
||||
# =============================================================================
|
||||
# ERP-SUITE: MECANICAS-DIESEL Backend - Dockerfile
|
||||
# =============================================================================
|
||||
# Puerto: 3041
|
||||
# Schemas BD: service_management, parts_management, vehicle_management
|
||||
# =============================================================================
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
RUN apk add --no-cache python3 make g++ curl
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
FROM base AS development
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
EXPOSE 3041
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
FROM base AS builder
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm prune --production
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
|
||||
|
||||
USER nodejs
|
||||
EXPOSE 3041
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:3041/health || exit 1
|
||||
|
||||
CMD ["node", "dist/server.js"]
|
||||
8044
package-lock.json
generated
Normal file
8044
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
package.json
Normal file
63
package.json
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@erp-suite/mecanicas-diesel-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "Backend for Mecánicas Diesel vertical - ERP Suite",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:cov": "jest --coverage",
|
||||
"db:migrate": "typeorm migration:run",
|
||||
"db:migrate:revert": "typeorm migration:revert"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"typeorm": "^0.3.17",
|
||||
"pg": "^8.11.3",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"zod": "^3.22.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"uuid": "^9.0.1",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"compression": "^1.7.4",
|
||||
"morgan": "^1.10.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"eslint": "^8.55.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"ts-jest": "^29.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"erp",
|
||||
"mecanicas",
|
||||
"diesel",
|
||||
"taller",
|
||||
"workshop"
|
||||
],
|
||||
"author": "ISEM Team",
|
||||
"license": "PROPRIETARY"
|
||||
}
|
||||
163
src/main.ts
Normal file
163
src/main.ts
Normal file
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Main Entry Point
|
||||
* Mecánicas Diesel Backend - ERP Suite
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import morgan from 'morgan';
|
||||
import { config } from 'dotenv';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
// Controllers
|
||||
import { createServiceOrderController } from './modules/service-management/controllers/service-order.controller';
|
||||
import { createQuoteController } from './modules/service-management/controllers/quote.controller';
|
||||
import { createDiagnosticController } from './modules/service-management/controllers/diagnostic.controller';
|
||||
import { createVehicleController } from './modules/vehicle-management/controllers/vehicle.controller';
|
||||
import { createFleetController } from './modules/vehicle-management/controllers/fleet.controller';
|
||||
import { createPartController } from './modules/parts-management/controllers/part.controller';
|
||||
import { createSupplierController } from './modules/parts-management/controllers/supplier.controller';
|
||||
|
||||
// Entities
|
||||
import { ServiceOrder } from './modules/service-management/entities/service-order.entity';
|
||||
import { OrderItem } from './modules/service-management/entities/order-item.entity';
|
||||
import { Diagnostic } from './modules/service-management/entities/diagnostic.entity';
|
||||
import { Quote } from './modules/service-management/entities/quote.entity';
|
||||
import { WorkBay } from './modules/service-management/entities/work-bay.entity';
|
||||
import { Service } from './modules/service-management/entities/service.entity';
|
||||
import { Vehicle } from './modules/vehicle-management/entities/vehicle.entity';
|
||||
import { Fleet } from './modules/vehicle-management/entities/fleet.entity';
|
||||
import { VehicleEngine } from './modules/vehicle-management/entities/vehicle-engine.entity';
|
||||
import { EngineCatalog } from './modules/vehicle-management/entities/engine-catalog.entity';
|
||||
import { MaintenanceReminder } from './modules/vehicle-management/entities/maintenance-reminder.entity';
|
||||
import { Part } from './modules/parts-management/entities/part.entity';
|
||||
import { PartCategory } from './modules/parts-management/entities/part-category.entity';
|
||||
import { Supplier } from './modules/parts-management/entities/supplier.entity';
|
||||
import { WarehouseLocation } from './modules/parts-management/entities/warehouse-location.entity';
|
||||
|
||||
// Load environment variables
|
||||
config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3011;
|
||||
|
||||
// Database configuration
|
||||
const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
username: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'mecanicas_diesel',
|
||||
schema: process.env.DB_SCHEMA || 'public',
|
||||
entities: [
|
||||
// Service Management
|
||||
ServiceOrder,
|
||||
OrderItem,
|
||||
Diagnostic,
|
||||
Quote,
|
||||
WorkBay,
|
||||
Service,
|
||||
// Vehicle Management
|
||||
Vehicle,
|
||||
Fleet,
|
||||
VehicleEngine,
|
||||
EngineCatalog,
|
||||
MaintenanceReminder,
|
||||
// Parts Management
|
||||
Part,
|
||||
PartCategory,
|
||||
Supplier,
|
||||
WarehouseLocation,
|
||||
],
|
||||
synchronize: process.env.NODE_ENV === 'development',
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000', 'http://localhost:5175'],
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'mecanicas-diesel-backend',
|
||||
version: '0.1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: AppDataSource.isInitialized ? 'connected' : 'disconnected',
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize database and routes
|
||||
async function bootstrap() {
|
||||
try {
|
||||
// Initialize database connection
|
||||
await AppDataSource.initialize();
|
||||
console.log('📦 Database connection established');
|
||||
|
||||
// Register API routes
|
||||
app.use('/api/v1/service-orders', createServiceOrderController(AppDataSource));
|
||||
app.use('/api/v1/quotes', createQuoteController(AppDataSource));
|
||||
app.use('/api/v1/diagnostics', createDiagnosticController(AppDataSource));
|
||||
app.use('/api/v1/vehicles', createVehicleController(AppDataSource));
|
||||
app.use('/api/v1/fleets', createFleetController(AppDataSource));
|
||||
app.use('/api/v1/parts', createPartController(AppDataSource));
|
||||
app.use('/api/v1/suppliers', createSupplierController(AppDataSource));
|
||||
|
||||
// API documentation endpoint
|
||||
app.get('/api/v1', (_req, res) => {
|
||||
res.json({
|
||||
name: 'Mecánicas Diesel API',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
serviceOrders: '/api/v1/service-orders',
|
||||
quotes: '/api/v1/quotes',
|
||||
diagnostics: '/api/v1/diagnostics',
|
||||
vehicles: '/api/v1/vehicles',
|
||||
fleets: '/api/v1/fleets',
|
||||
parts: '/api/v1/parts',
|
||||
suppliers: '/api/v1/suppliers',
|
||||
},
|
||||
documentation: '/api/v1/docs',
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: 'Not Found' });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🔧 Mecánicas Diesel Backend running on port ${PORT}`);
|
||||
console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`🏥 Health check: http://localhost:${PORT}/health`);
|
||||
console.log(`📚 API Root: http://localhost:${PORT}/api/v1`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
259
src/modules/parts-management/controllers/part.controller.ts
Normal file
259
src/modules/parts-management/controllers/part.controller.ts
Normal file
@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Part Controller
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* REST API endpoints for parts/inventory management.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PartService, PartFilters } from '../services/part.service';
|
||||
|
||||
interface TenantRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createPartController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new PartService(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);
|
||||
|
||||
/**
|
||||
* Create a new part
|
||||
* POST /api/parts
|
||||
*/
|
||||
router.post('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const part = await service.create(req.tenantId!, req.body);
|
||||
res.status(201).json(part);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List parts with filters
|
||||
* GET /api/parts
|
||||
*/
|
||||
router.get('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const filters: PartFilters = {
|
||||
categoryId: req.query.categoryId as string,
|
||||
preferredSupplierId: req.query.supplierId as string,
|
||||
brand: req.query.brand as string,
|
||||
search: req.query.search as string,
|
||||
lowStock: req.query.lowStock === 'true',
|
||||
isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : 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 inventory statistics
|
||||
* GET /api/parts/stats
|
||||
*/
|
||||
router.get('/stats', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const stats = await service.getInventoryValue(req.tenantId!);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get parts with low stock
|
||||
* GET /api/parts/low-stock
|
||||
*/
|
||||
router.get('/low-stock', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const parts = await service.getLowStockParts(req.tenantId!);
|
||||
res.json(parts);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Search parts (for autocomplete)
|
||||
* GET /api/parts/search
|
||||
*/
|
||||
router.get('/search', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const query = req.query.q as string || '';
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
|
||||
const parts = await service.search(req.tenantId!, query, limit);
|
||||
res.json(parts);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single part
|
||||
* GET /api/parts/:id
|
||||
*/
|
||||
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const part = await service.findById(req.tenantId!, req.params.id);
|
||||
if (!part) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.json(part);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get by SKU
|
||||
* GET /api/parts/sku/:sku
|
||||
*/
|
||||
router.get('/sku/:sku', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const part = await service.findBySku(req.tenantId!, req.params.sku);
|
||||
if (!part) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.json(part);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get by barcode
|
||||
* GET /api/parts/barcode/:barcode
|
||||
*/
|
||||
router.get('/barcode/:barcode', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const part = await service.findByBarcode(req.tenantId!, req.params.barcode);
|
||||
if (!part) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.json(part);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update part
|
||||
* PATCH /api/parts/:id
|
||||
*/
|
||||
router.patch('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const part = await service.update(req.tenantId!, req.params.id, req.body);
|
||||
if (!part) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.json(part);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Adjust stock
|
||||
* POST /api/parts/:id/stock-adjustment
|
||||
*/
|
||||
router.post('/:id/stock-adjustment', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const part = await service.adjustStock(req.tenantId!, req.params.id, req.body);
|
||||
if (!part) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.json(part);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reserve stock
|
||||
* POST /api/parts/:id/reserve
|
||||
*/
|
||||
router.post('/:id/reserve', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.reserveStock(req.tenantId!, req.params.id, req.body.quantity);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Release reserved stock
|
||||
* POST /api/parts/:id/release
|
||||
*/
|
||||
router.post('/:id/release', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.releaseStock(req.tenantId!, req.params.id, req.body.quantity);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Consume stock (order completed)
|
||||
* POST /api/parts/:id/consume
|
||||
*/
|
||||
router.post('/:id/consume', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.consumeStock(req.tenantId!, req.params.id, req.body.quantity);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deactivate part
|
||||
* DELETE /api/parts/:id
|
||||
*/
|
||||
router.delete('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.deactivate(req.tenantId!, req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Part not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
149
src/modules/parts-management/controllers/supplier.controller.ts
Normal file
149
src/modules/parts-management/controllers/supplier.controller.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Supplier Controller
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* REST API endpoints for supplier management.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SupplierService } from '../services/supplier.service';
|
||||
|
||||
interface TenantRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createSupplierController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new SupplierService(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);
|
||||
|
||||
/**
|
||||
* Create a new supplier
|
||||
* POST /api/suppliers
|
||||
*/
|
||||
router.post('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const supplier = await service.create(req.tenantId!, req.body);
|
||||
res.status(201).json(supplier);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List suppliers
|
||||
* GET /api/suppliers
|
||||
*/
|
||||
router.get('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const filters = {
|
||||
search: req.query.search as string,
|
||||
isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : 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 });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Search suppliers (for autocomplete)
|
||||
* GET /api/suppliers/search
|
||||
*/
|
||||
router.get('/search', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const query = req.query.q as string || '';
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
|
||||
const suppliers = await service.search(req.tenantId!, query, limit);
|
||||
res.json(suppliers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single supplier
|
||||
* GET /api/suppliers/:id
|
||||
*/
|
||||
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const supplier = await service.findById(req.tenantId!, req.params.id);
|
||||
if (!supplier) {
|
||||
return res.status(404).json({ error: 'Supplier not found' });
|
||||
}
|
||||
res.json(supplier);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get supplier with statistics
|
||||
* GET /api/suppliers/:id/stats
|
||||
*/
|
||||
router.get('/:id/stats', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const result = await service.getSupplierWithStats(req.tenantId!, req.params.id);
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Supplier not found' });
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update supplier
|
||||
* PATCH /api/suppliers/:id
|
||||
*/
|
||||
router.patch('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const supplier = await service.update(req.tenantId!, req.params.id, req.body);
|
||||
if (!supplier) {
|
||||
return res.status(404).json({ error: 'Supplier not found' });
|
||||
}
|
||||
res.json(supplier);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deactivate supplier
|
||||
* DELETE /api/suppliers/:id
|
||||
*/
|
||||
router.delete('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.deactivate(req.tenantId!, req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Supplier not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
9
src/modules/parts-management/entities/index.ts
Normal file
9
src/modules/parts-management/entities/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Parts Management Entities Index
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*/
|
||||
|
||||
export * from './part.entity';
|
||||
export * from './part-category.entity';
|
||||
export * from './supplier.entity';
|
||||
export * from './warehouse-location.entity';
|
||||
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Part Category Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents part categories with hierarchical structure.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'part_categories', schema: 'parts_management' })
|
||||
@Index('idx_part_categories_tenant', ['tenantId'])
|
||||
@Index('idx_part_categories_parent', ['parentId'])
|
||||
export class PartCategory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 300, nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
|
||||
parentId?: string;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'integer', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => PartCategory, category => category.children, { nullable: true })
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent?: PartCategory;
|
||||
|
||||
@OneToMany(() => PartCategory, category => category.parent)
|
||||
children: PartCategory[];
|
||||
}
|
||||
127
src/modules/parts-management/entities/part.entity.ts
Normal file
127
src/modules/parts-management/entities/part.entity.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Part Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents parts/spare parts inventory.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
Check,
|
||||
} from 'typeorm';
|
||||
import { PartCategory } from './part-category.entity';
|
||||
import { Supplier } from './supplier.entity';
|
||||
import { WarehouseLocation } from './warehouse-location.entity';
|
||||
|
||||
@Entity({ name: 'parts', schema: 'parts_management' })
|
||||
@Index('idx_parts_tenant', ['tenantId'])
|
||||
@Index('idx_parts_sku', ['sku'])
|
||||
@Index('idx_parts_barcode', ['barcode'])
|
||||
@Index('idx_parts_category', ['categoryId'])
|
||||
@Index('idx_parts_supplier', ['preferredSupplierId'])
|
||||
@Check('chk_min_max_stock', '"max_stock" IS NULL OR "max_stock" >= "min_stock"')
|
||||
export class Part {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
sku: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 300 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ name: 'category_id', type: 'uuid', nullable: true })
|
||||
categoryId?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
brand?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
manufacturer?: string;
|
||||
|
||||
@Column({ name: 'compatible_engines', type: 'text', array: true, nullable: true })
|
||||
compatibleEngines?: string[];
|
||||
|
||||
// Pricing
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||
cost?: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||
price: number;
|
||||
|
||||
// Inventory
|
||||
@Column({ name: 'current_stock', type: 'decimal', precision: 10, scale: 3, default: 0 })
|
||||
currentStock: number;
|
||||
|
||||
@Column({ name: 'reserved_stock', type: 'decimal', precision: 10, scale: 3, default: 0 })
|
||||
reservedStock: number;
|
||||
|
||||
@Column({ name: 'min_stock', type: 'decimal', precision: 10, scale: 3, default: 0 })
|
||||
minStock: number;
|
||||
|
||||
@Column({ name: 'max_stock', type: 'decimal', precision: 10, scale: 3, nullable: true })
|
||||
maxStock?: number;
|
||||
|
||||
@Column({ name: 'reorder_point', type: 'decimal', precision: 10, scale: 3, nullable: true })
|
||||
reorderPoint?: number;
|
||||
|
||||
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
||||
locationId?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pza' })
|
||||
unit: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
barcode?: string;
|
||||
|
||||
@Column({ name: 'preferred_supplier_id', type: 'uuid', nullable: true })
|
||||
preferredSupplierId?: string;
|
||||
|
||||
@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;
|
||||
|
||||
// Computed
|
||||
get availableStock(): number {
|
||||
return this.currentStock - this.reservedStock;
|
||||
}
|
||||
|
||||
get isLowStock(): boolean {
|
||||
return this.currentStock <= this.minStock;
|
||||
}
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => PartCategory, { nullable: true })
|
||||
@JoinColumn({ name: 'category_id' })
|
||||
category?: PartCategory;
|
||||
|
||||
@ManyToOne(() => Supplier, { nullable: true })
|
||||
@JoinColumn({ name: 'preferred_supplier_id' })
|
||||
preferredSupplier?: Supplier;
|
||||
|
||||
@ManyToOne(() => WarehouseLocation, { nullable: true })
|
||||
@JoinColumn({ name: 'location_id' })
|
||||
location?: WarehouseLocation;
|
||||
|
||||
// @OneToMany(() => PartAlternate, alt => alt.part)
|
||||
// alternates: PartAlternate[];
|
||||
}
|
||||
70
src/modules/parts-management/entities/supplier.entity.ts
Normal file
70
src/modules/parts-management/entities/supplier.entity.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Supplier Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents parts suppliers.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'suppliers', schema: 'parts_management' })
|
||||
@Index('idx_suppliers_tenant', ['tenantId'])
|
||||
@Index('idx_suppliers_name', ['name'])
|
||||
export class Supplier {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'legal_name', type: 'varchar', length: 300, nullable: true })
|
||||
legalName?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 13, nullable: true })
|
||||
rfc?: string;
|
||||
|
||||
// Contact
|
||||
@Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true })
|
||||
contactName?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
email?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
phone?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
address?: string;
|
||||
|
||||
// Terms
|
||||
@Column({ name: 'credit_days', type: 'integer', default: 0 })
|
||||
creditDays: number;
|
||||
|
||||
@Column({ name: 'discount_pct', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPct: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 3, scale: 2, nullable: true })
|
||||
rating?: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@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;
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Warehouse Location Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents storage locations in the warehouse.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'warehouse_locations', schema: 'parts_management' })
|
||||
@Index('idx_locations_tenant', ['tenantId'])
|
||||
@Index('idx_locations_zone', ['zone'])
|
||||
export class WarehouseLocation {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
name?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, nullable: true })
|
||||
zone?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, nullable: true })
|
||||
aisle?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, nullable: true })
|
||||
level?: string;
|
||||
|
||||
@Column({ name: 'max_weight', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
maxWeight?: number;
|
||||
|
||||
@Column({ name: 'max_volume', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
maxVolume?: 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;
|
||||
}
|
||||
18
src/modules/parts-management/index.ts
Normal file
18
src/modules/parts-management/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Parts Management Module
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*/
|
||||
|
||||
// Entities
|
||||
export { Part } from './entities/part.entity';
|
||||
export { PartCategory } from './entities/part-category.entity';
|
||||
export { Supplier } from './entities/supplier.entity';
|
||||
export { WarehouseLocation } from './entities/warehouse-location.entity';
|
||||
|
||||
// Services
|
||||
export { PartService, CreatePartDto, UpdatePartDto, PartFilters, StockAdjustmentDto } from './services/part.service';
|
||||
export { SupplierService, CreateSupplierDto, UpdateSupplierDto } from './services/supplier.service';
|
||||
|
||||
// Controllers
|
||||
export { createPartController } from './controllers/part.controller';
|
||||
export { createSupplierController } from './controllers/supplier.controller';
|
||||
341
src/modules/parts-management/services/part.service.ts
Normal file
341
src/modules/parts-management/services/part.service.ts
Normal file
@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Part Service
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for parts/inventory management.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Part } from '../entities/part.entity';
|
||||
|
||||
// DTOs
|
||||
export interface CreatePartDto {
|
||||
sku: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
categoryId?: string;
|
||||
preferredSupplierId?: string;
|
||||
brand?: string;
|
||||
manufacturer?: string;
|
||||
compatibleEngines?: string[];
|
||||
unit?: string;
|
||||
cost?: number;
|
||||
price: number;
|
||||
minStock?: number;
|
||||
maxStock?: number;
|
||||
reorderPoint?: number;
|
||||
locationId?: string;
|
||||
barcode?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePartDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
categoryId?: string;
|
||||
preferredSupplierId?: string;
|
||||
brand?: string;
|
||||
manufacturer?: string;
|
||||
compatibleEngines?: string[];
|
||||
unit?: string;
|
||||
cost?: number;
|
||||
price?: number;
|
||||
minStock?: number;
|
||||
maxStock?: number;
|
||||
reorderPoint?: number;
|
||||
locationId?: string;
|
||||
barcode?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface PartFilters {
|
||||
categoryId?: string;
|
||||
preferredSupplierId?: string;
|
||||
brand?: string;
|
||||
search?: string;
|
||||
lowStock?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface StockAdjustmentDto {
|
||||
quantity: number;
|
||||
reason: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
export class PartService {
|
||||
private partRepository: Repository<Part>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.partRepository = dataSource.getRepository(Part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new part
|
||||
*/
|
||||
async create(tenantId: string, dto: CreatePartDto): Promise<Part> {
|
||||
// Check SKU uniqueness
|
||||
const existing = await this.partRepository.findOne({
|
||||
where: { tenantId, sku: dto.sku },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Part with SKU ${dto.sku} already exists`);
|
||||
}
|
||||
|
||||
const part = this.partRepository.create({
|
||||
tenantId,
|
||||
sku: dto.sku,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
categoryId: dto.categoryId,
|
||||
preferredSupplierId: dto.preferredSupplierId,
|
||||
brand: dto.brand,
|
||||
manufacturer: dto.manufacturer,
|
||||
compatibleEngines: dto.compatibleEngines,
|
||||
unit: dto.unit || 'pza',
|
||||
cost: dto.cost,
|
||||
price: dto.price,
|
||||
minStock: dto.minStock || 0,
|
||||
maxStock: dto.maxStock,
|
||||
reorderPoint: dto.reorderPoint,
|
||||
locationId: dto.locationId,
|
||||
barcode: dto.barcode,
|
||||
currentStock: 0,
|
||||
reservedStock: 0,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return this.partRepository.save(part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find part by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<Part | null> {
|
||||
return this.partRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find part by SKU
|
||||
*/
|
||||
async findBySku(tenantId: string, sku: string): Promise<Part | null> {
|
||||
return this.partRepository.findOne({
|
||||
where: { tenantId, sku },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find part by barcode
|
||||
*/
|
||||
async findByBarcode(tenantId: string, barcode: string): Promise<Part | null> {
|
||||
return this.partRepository.findOne({
|
||||
where: { tenantId, barcode },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List parts with filters
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: PartFilters = {},
|
||||
pagination = { page: 1, limit: 20 }
|
||||
) {
|
||||
const queryBuilder = this.partRepository.createQueryBuilder('part')
|
||||
.where('part.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (filters.categoryId) {
|
||||
queryBuilder.andWhere('part.category_id = :categoryId', { categoryId: filters.categoryId });
|
||||
}
|
||||
if (filters.preferredSupplierId) {
|
||||
queryBuilder.andWhere('part.preferred_supplier_id = :supplierId', { supplierId: filters.preferredSupplierId });
|
||||
}
|
||||
if (filters.brand) {
|
||||
queryBuilder.andWhere('part.brand = :brand', { brand: filters.brand });
|
||||
}
|
||||
if (filters.isActive !== undefined) {
|
||||
queryBuilder.andWhere('part.is_active = :isActive', { isActive: filters.isActive });
|
||||
}
|
||||
if (filters.lowStock) {
|
||||
queryBuilder.andWhere('part.current_stock <= part.min_stock');
|
||||
}
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(part.sku ILIKE :search OR part.name ILIKE :search OR part.barcode ILIKE :search OR part.description ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (pagination.page - 1) * pagination.limit;
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('part.name', 'ASC')
|
||||
.skip(skip)
|
||||
.take(pagination.limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
totalPages: Math.ceil(total / pagination.limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update part
|
||||
*/
|
||||
async update(tenantId: string, id: string, dto: UpdatePartDto): Promise<Part | null> {
|
||||
const part = await this.findById(tenantId, id);
|
||||
if (!part) return null;
|
||||
|
||||
Object.assign(part, dto);
|
||||
return this.partRepository.save(part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust stock (increase or decrease)
|
||||
*/
|
||||
async adjustStock(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: StockAdjustmentDto
|
||||
): Promise<Part | null> {
|
||||
const part = await this.findById(tenantId, id);
|
||||
if (!part) return null;
|
||||
|
||||
const newStock = part.currentStock + dto.quantity;
|
||||
|
||||
if (newStock < 0) {
|
||||
throw new Error('Stock cannot be negative');
|
||||
}
|
||||
|
||||
part.currentStock = newStock;
|
||||
|
||||
// TODO: Create stock movement record for audit trail
|
||||
|
||||
return this.partRepository.save(part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve stock for an order
|
||||
*/
|
||||
async reserveStock(tenantId: string, id: string, quantity: number): Promise<boolean> {
|
||||
const part = await this.findById(tenantId, id);
|
||||
if (!part) return false;
|
||||
|
||||
const availableStock = part.currentStock - part.reservedStock;
|
||||
|
||||
if (quantity > availableStock) {
|
||||
throw new Error(`Insufficient stock. Available: ${availableStock}, Requested: ${quantity}`);
|
||||
}
|
||||
|
||||
part.reservedStock += quantity;
|
||||
await this.partRepository.save(part);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release reserved stock
|
||||
*/
|
||||
async releaseStock(tenantId: string, id: string, quantity: number): Promise<boolean> {
|
||||
const part = await this.findById(tenantId, id);
|
||||
if (!part) return false;
|
||||
|
||||
part.reservedStock = Math.max(0, part.reservedStock - quantity);
|
||||
await this.partRepository.save(part);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume reserved stock (when order is completed)
|
||||
*/
|
||||
async consumeStock(tenantId: string, id: string, quantity: number): Promise<boolean> {
|
||||
const part = await this.findById(tenantId, id);
|
||||
if (!part) return false;
|
||||
|
||||
part.reservedStock = Math.max(0, part.reservedStock - quantity);
|
||||
part.currentStock = Math.max(0, part.currentStock - quantity);
|
||||
await this.partRepository.save(part);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parts with low stock
|
||||
*/
|
||||
async getLowStockParts(tenantId: string): Promise<Part[]> {
|
||||
return this.partRepository
|
||||
.createQueryBuilder('part')
|
||||
.where('part.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('part.is_active = true')
|
||||
.andWhere('part.current_stock <= part.min_stock')
|
||||
.orderBy('part.current_stock', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory value
|
||||
*/
|
||||
async getInventoryValue(tenantId: string): Promise<{
|
||||
totalCostValue: number;
|
||||
totalSaleValue: number;
|
||||
totalItems: number;
|
||||
lowStockCount: number;
|
||||
}> {
|
||||
const result = await this.partRepository
|
||||
.createQueryBuilder('part')
|
||||
.select('SUM(part.current_stock * COALESCE(part.cost, 0))', 'costValue')
|
||||
.addSelect('SUM(part.current_stock * part.price)', 'saleValue')
|
||||
.addSelect('SUM(part.current_stock)', 'totalItems')
|
||||
.where('part.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('part.is_active = true')
|
||||
.getRawOne();
|
||||
|
||||
const lowStockCount = await this.partRepository
|
||||
.createQueryBuilder('part')
|
||||
.where('part.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('part.is_active = true')
|
||||
.andWhere('part.current_stock <= part.min_stock')
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
totalCostValue: parseFloat(result?.costValue) || 0,
|
||||
totalSaleValue: parseFloat(result?.saleValue) || 0,
|
||||
totalItems: parseInt(result?.totalItems, 10) || 0,
|
||||
lowStockCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search parts for autocomplete
|
||||
*/
|
||||
async search(tenantId: string, query: string, limit = 10): Promise<Part[]> {
|
||||
return this.partRepository
|
||||
.createQueryBuilder('part')
|
||||
.where('part.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('part.is_active = true')
|
||||
.andWhere(
|
||||
'(part.sku ILIKE :query OR part.name ILIKE :query OR part.barcode ILIKE :query)',
|
||||
{ query: `%${query}%` }
|
||||
)
|
||||
.orderBy('part.name', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate part
|
||||
*/
|
||||
async deactivate(tenantId: string, id: string): Promise<boolean> {
|
||||
const part = await this.findById(tenantId, id);
|
||||
if (!part) return false;
|
||||
|
||||
part.isActive = false;
|
||||
await this.partRepository.save(part);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
189
src/modules/parts-management/services/supplier.service.ts
Normal file
189
src/modules/parts-management/services/supplier.service.ts
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Supplier Service
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for supplier management.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Supplier } from '../entities/supplier.entity';
|
||||
import { Part } from '../entities/part.entity';
|
||||
|
||||
// DTOs
|
||||
export interface CreateSupplierDto {
|
||||
name: string;
|
||||
legalName?: string;
|
||||
rfc?: string;
|
||||
contactName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
creditDays?: number;
|
||||
discountPct?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSupplierDto {
|
||||
name?: string;
|
||||
legalName?: string;
|
||||
rfc?: string;
|
||||
contactName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
creditDays?: number;
|
||||
discountPct?: number;
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SupplierService {
|
||||
private supplierRepository: Repository<Supplier>;
|
||||
private partRepository: Repository<Part>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.supplierRepository = dataSource.getRepository(Supplier);
|
||||
this.partRepository = dataSource.getRepository(Part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new supplier
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateSupplierDto): Promise<Supplier> {
|
||||
const supplier = this.supplierRepository.create({
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
legalName: dto.legalName,
|
||||
rfc: dto.rfc,
|
||||
contactName: dto.contactName,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
address: dto.address,
|
||||
creditDays: dto.creditDays || 0,
|
||||
discountPct: dto.discountPct || 0,
|
||||
notes: dto.notes,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return this.supplierRepository.save(supplier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find supplier by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<Supplier | null> {
|
||||
return this.supplierRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List suppliers
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: { search?: string; isActive?: boolean } = {},
|
||||
pagination = { page: 1, limit: 20 }
|
||||
) {
|
||||
const queryBuilder = this.supplierRepository.createQueryBuilder('supplier')
|
||||
.where('supplier.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
queryBuilder.andWhere('supplier.is_active = :isActive', { isActive: filters.isActive });
|
||||
}
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(supplier.name ILIKE :search OR supplier.contact_name ILIKE :search OR supplier.rfc ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (pagination.page - 1) * pagination.limit;
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('supplier.name', 'ASC')
|
||||
.skip(skip)
|
||||
.take(pagination.limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
totalPages: Math.ceil(total / pagination.limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update supplier
|
||||
*/
|
||||
async update(tenantId: string, id: string, dto: UpdateSupplierDto): Promise<Supplier | null> {
|
||||
const supplier = await this.findById(tenantId, id);
|
||||
if (!supplier) return null;
|
||||
|
||||
Object.assign(supplier, dto);
|
||||
return this.supplierRepository.save(supplier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate supplier
|
||||
*/
|
||||
async deactivate(tenantId: string, id: string): Promise<boolean> {
|
||||
const supplier = await this.findById(tenantId, id);
|
||||
if (!supplier) return false;
|
||||
|
||||
supplier.isActive = false;
|
||||
await this.supplierRepository.save(supplier);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supplier with part count
|
||||
*/
|
||||
async getSupplierWithStats(tenantId: string, id: string): Promise<{
|
||||
supplier: Supplier;
|
||||
partCount: number;
|
||||
totalInventoryValue: number;
|
||||
} | null> {
|
||||
const supplier = await this.findById(tenantId, id);
|
||||
if (!supplier) return null;
|
||||
|
||||
const [partCount, inventoryResult] = await Promise.all([
|
||||
this.partRepository
|
||||
.createQueryBuilder('part')
|
||||
.where('part.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('part.supplier_id = :supplierId', { supplierId: id })
|
||||
.getCount(),
|
||||
this.partRepository
|
||||
.createQueryBuilder('part')
|
||||
.select('SUM(part.current_stock * part.cost_price)', 'value')
|
||||
.where('part.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('part.supplier_id = :supplierId', { supplierId: id })
|
||||
.getRawOne(),
|
||||
]);
|
||||
|
||||
return {
|
||||
supplier,
|
||||
partCount,
|
||||
totalInventoryValue: parseFloat(inventoryResult?.value) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search suppliers for autocomplete
|
||||
*/
|
||||
async search(tenantId: string, query: string, limit = 10): Promise<Supplier[]> {
|
||||
return this.supplierRepository
|
||||
.createQueryBuilder('supplier')
|
||||
.where('supplier.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('supplier.is_active = true')
|
||||
.andWhere(
|
||||
'(supplier.name ILIKE :query OR supplier.rfc ILIKE :query)',
|
||||
{ query: `%${query}%` }
|
||||
)
|
||||
.orderBy('supplier.name', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Diagnostic Controller
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* REST API endpoints for vehicle diagnostics.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DiagnosticService } from '../services/diagnostic.service';
|
||||
import { DiagnosticType, DiagnosticResult } from '../entities/diagnostic.entity';
|
||||
|
||||
interface TenantRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createDiagnosticController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new DiagnosticService(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);
|
||||
|
||||
/**
|
||||
* Create a new diagnostic
|
||||
* POST /api/diagnostics
|
||||
*/
|
||||
router.post('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const diagnostic = await service.create(req.tenantId!, req.body);
|
||||
res.status(201).json(diagnostic);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single diagnostic
|
||||
* GET /api/diagnostics/:id
|
||||
*/
|
||||
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const diagnostic = await service.findById(req.tenantId!, req.params.id);
|
||||
if (!diagnostic) {
|
||||
return res.status(404).json({ error: 'Diagnostic not found' });
|
||||
}
|
||||
res.json(diagnostic);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get diagnostics by vehicle
|
||||
* GET /api/diagnostics/vehicle/:vehicleId
|
||||
*/
|
||||
router.get('/vehicle/:vehicleId', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const diagnostics = await service.findByVehicle(req.tenantId!, req.params.vehicleId);
|
||||
res.json(diagnostics);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get diagnostics by order
|
||||
* GET /api/diagnostics/order/:orderId
|
||||
*/
|
||||
router.get('/order/:orderId', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const diagnostics = await service.findByOrder(req.tenantId!, req.params.orderId);
|
||||
res.json(diagnostics);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get vehicle diagnostic statistics
|
||||
* GET /api/diagnostics/vehicle/:vehicleId/stats
|
||||
*/
|
||||
router.get('/vehicle/:vehicleId/stats', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const stats = await service.getVehicleStats(req.tenantId!, req.params.vehicleId);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update diagnostic result
|
||||
* PATCH /api/diagnostics/:id/result
|
||||
*/
|
||||
router.patch('/:id/result', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const { result, summary } = req.body;
|
||||
const diagnostic = await service.updateResult(
|
||||
req.tenantId!,
|
||||
req.params.id,
|
||||
result as DiagnosticResult,
|
||||
summary
|
||||
);
|
||||
if (!diagnostic) {
|
||||
return res.status(404).json({ error: 'Diagnostic not found' });
|
||||
}
|
||||
res.json(diagnostic);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse DTC codes from raw data
|
||||
* POST /api/diagnostics/parse-dtc
|
||||
*/
|
||||
router.post('/parse-dtc', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const items = service.parseDTCCodes(req.body.rawData || {});
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Analyze injector test results
|
||||
* POST /api/diagnostics/analyze-injectors
|
||||
*/
|
||||
router.post('/analyze-injectors', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const items = service.analyzeInjectorTest(req.body.rawData || {});
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
234
src/modules/service-management/controllers/quote.controller.ts
Normal file
234
src/modules/service-management/controllers/quote.controller.ts
Normal file
@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Quote Controller
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* REST API endpoints for quotations.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { QuoteService } from '../services/quote.service';
|
||||
import { QuoteStatus } from '../entities/quote.entity';
|
||||
|
||||
interface TenantRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createQuoteController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new QuoteService(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);
|
||||
|
||||
/**
|
||||
* Create a new quote
|
||||
* POST /api/quotes
|
||||
*/
|
||||
router.post('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const quote = await service.create(req.tenantId!, req.body, req.userId);
|
||||
res.status(201).json(quote);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List quotes with filters
|
||||
* GET /api/quotes
|
||||
*/
|
||||
router.get('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const filters = {
|
||||
status: req.query.status as QuoteStatus,
|
||||
customerId: req.query.customerId as string,
|
||||
vehicleId: req.query.vehicleId as string,
|
||||
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
|
||||
toDate: req.query.toDate ? new Date(req.query.toDate 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 quote statistics
|
||||
* GET /api/quotes/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 });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update expired quotes
|
||||
* POST /api/quotes/update-expired
|
||||
*/
|
||||
router.post('/update-expired', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const count = await service.updateExpiredQuotes(req.tenantId!);
|
||||
res.json({ updated: count });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single quote
|
||||
* GET /api/quotes/:id
|
||||
*/
|
||||
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const quote = await service.findById(req.tenantId!, req.params.id);
|
||||
if (!quote) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json(quote);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get by quote number
|
||||
* GET /api/quotes/number/:quoteNumber
|
||||
*/
|
||||
router.get('/number/:quoteNumber', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const quote = await service.findByNumber(req.tenantId!, req.params.quoteNumber);
|
||||
if (!quote) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json(quote);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Send quote to customer
|
||||
* POST /api/quotes/:id/send
|
||||
*/
|
||||
router.post('/:id/send', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const channel = req.body.channel || 'email';
|
||||
const quote = await service.send(req.tenantId!, req.params.id, channel);
|
||||
if (!quote) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json(quote);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark quote as viewed
|
||||
* POST /api/quotes/:id/view
|
||||
*/
|
||||
router.post('/:id/view', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const quote = await service.markViewed(req.tenantId!, req.params.id);
|
||||
if (!quote) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json(quote);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Approve quote (customer action)
|
||||
* POST /api/quotes/:id/approve
|
||||
*/
|
||||
router.post('/:id/approve', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const approvalData = {
|
||||
approvedByName: req.body.approvedByName,
|
||||
approvalSignature: req.body.approvalSignature,
|
||||
approvalIp: req.ip,
|
||||
};
|
||||
const quote = await service.approve(req.tenantId!, req.params.id, approvalData);
|
||||
if (!quote) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json(quote);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reject quote
|
||||
* POST /api/quotes/:id/reject
|
||||
*/
|
||||
router.post('/:id/reject', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const quote = await service.reject(req.tenantId!, req.params.id, req.body.reason);
|
||||
if (!quote) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json(quote);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert quote to service order
|
||||
* POST /api/quotes/:id/convert
|
||||
*/
|
||||
router.post('/:id/convert', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const order = await service.convertToOrder(req.tenantId!, req.params.id, req.userId);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.status(201).json(order);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply discount to quote
|
||||
* POST /api/quotes/:id/discount
|
||||
*/
|
||||
router.post('/:id/discount', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const quote = await service.applyDiscount(req.tenantId!, req.params.id, req.body);
|
||||
if (!quote) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
res.json(quote);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Service Order Controller
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* REST API endpoints for service orders.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServiceOrderService, ServiceOrderFilters } from '../services/service-order.service';
|
||||
import { ServiceOrderStatus, ServiceOrderPriority } from '../entities/service-order.entity';
|
||||
import { OrderItemType } from '../entities/order-item.entity';
|
||||
|
||||
// Middleware type for tenant extraction
|
||||
interface TenantRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createServiceOrderController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new ServiceOrderService(dataSource);
|
||||
|
||||
// Middleware to extract tenant from request
|
||||
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);
|
||||
|
||||
/**
|
||||
* Create a new service order
|
||||
* POST /api/service-orders
|
||||
*/
|
||||
router.post('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const order = await service.create(req.tenantId!, req.body, req.userId);
|
||||
res.status(201).json(order);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List service orders with filters
|
||||
* GET /api/service-orders
|
||||
*/
|
||||
router.get('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const filters: ServiceOrderFilters = {
|
||||
status: req.query.status as ServiceOrderStatus,
|
||||
priority: req.query.priority as ServiceOrderPriority,
|
||||
customerId: req.query.customerId as string,
|
||||
vehicleId: req.query.vehicleId as string,
|
||||
assignedTo: req.query.assignedTo as string,
|
||||
bayId: req.query.bayId as string,
|
||||
search: req.query.search as string,
|
||||
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
|
||||
toDate: req.query.toDate ? new Date(req.query.toDate 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 orders grouped by status (Kanban)
|
||||
* GET /api/service-orders/kanban
|
||||
*/
|
||||
router.get('/kanban', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const result = await service.getOrdersByStatus(req.tenantId!);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get dashboard statistics
|
||||
* GET /api/service-orders/stats
|
||||
*/
|
||||
router.get('/stats', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const stats = await service.getDashboardStats(req.tenantId!);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single service order
|
||||
* GET /api/service-orders/:id
|
||||
*/
|
||||
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const order = await service.findById(req.tenantId!, req.params.id);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Service order not found' });
|
||||
}
|
||||
res.json(order);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get by order number
|
||||
* GET /api/service-orders/number/:orderNumber
|
||||
*/
|
||||
router.get('/number/:orderNumber', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const order = await service.findByOrderNumber(req.tenantId!, req.params.orderNumber);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Service order not found' });
|
||||
}
|
||||
res.json(order);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update service order
|
||||
* PATCH /api/service-orders/:id
|
||||
*/
|
||||
router.patch('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const order = await service.update(req.tenantId!, req.params.id, req.body);
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Service order not found' });
|
||||
}
|
||||
res.json(order);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Add item to order
|
||||
* POST /api/service-orders/:id/items
|
||||
*/
|
||||
router.post('/:id/items', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const item = await service.addItem(req.tenantId!, req.params.id, req.body);
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Service order not found' });
|
||||
}
|
||||
res.status(201).json(item);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get order items
|
||||
* GET /api/service-orders/:id/items
|
||||
*/
|
||||
router.get('/:id/items', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const items = await service.getItems(req.params.id);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update order item
|
||||
* PATCH /api/service-orders/:id/items/:itemId
|
||||
*/
|
||||
router.patch('/:id/items/:itemId', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const item = await service.updateItem(req.params.itemId, req.body);
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Item not found' });
|
||||
}
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove order item
|
||||
* DELETE /api/service-orders/:id/items/:itemId
|
||||
*/
|
||||
router.delete('/:id/items/:itemId', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.removeItem(req.params.itemId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Item not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
93
src/modules/service-management/entities/diagnostic.entity.ts
Normal file
93
src/modules/service-management/entities/diagnostic.entity.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Diagnostic Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents diagnostic tests performed on vehicles.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ServiceOrder } from './service-order.entity';
|
||||
|
||||
export enum DiagnosticType {
|
||||
SCANNER = 'scanner',
|
||||
INJECTOR_TEST = 'injector_test',
|
||||
PUMP_TEST = 'pump_test',
|
||||
COMPRESSION = 'compression',
|
||||
TURBO_TEST = 'turbo_test',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum DiagnosticResult {
|
||||
PASS = 'pass',
|
||||
FAIL = 'fail',
|
||||
NEEDS_ATTENTION = 'needs_attention',
|
||||
}
|
||||
|
||||
@Entity({ name: 'diagnostics', schema: 'service_management' })
|
||||
@Index('idx_diagnostics_tenant', ['tenantId'])
|
||||
@Index('idx_diagnostics_vehicle', ['vehicleId'])
|
||||
@Index('idx_diagnostics_order', ['orderId'])
|
||||
export class Diagnostic {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'uuid', nullable: true })
|
||||
orderId?: string;
|
||||
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId: string;
|
||||
|
||||
@Column({ name: 'diagnostic_type', type: 'varchar', length: 50 })
|
||||
diagnosticType: DiagnosticType;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
equipment?: string;
|
||||
|
||||
@Column({ name: 'performed_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||
performedAt: Date;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'uuid', nullable: true })
|
||||
performedBy?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
result?: DiagnosticResult;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
summary?: string;
|
||||
|
||||
@Column({ name: 'raw_data', type: 'jsonb', nullable: true })
|
||||
rawData?: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ServiceOrder, { nullable: true })
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order?: ServiceOrder;
|
||||
|
||||
// @OneToMany(() => DiagnosticItem, item => item.diagnostic)
|
||||
// items: DiagnosticItem[];
|
||||
|
||||
// @OneToMany(() => DiagnosticPhoto, photo => photo.diagnostic)
|
||||
// photos: DiagnosticPhoto[];
|
||||
|
||||
// @OneToMany(() => DiagnosticRecommendation, rec => rec.diagnostic)
|
||||
// recommendations: DiagnosticRecommendation[];
|
||||
}
|
||||
11
src/modules/service-management/entities/index.ts
Normal file
11
src/modules/service-management/entities/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Service Management Entities Index
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*/
|
||||
|
||||
export * from './service-order.entity';
|
||||
export * from './order-item.entity';
|
||||
export * from './diagnostic.entity';
|
||||
export * from './quote.entity';
|
||||
export * from './work-bay.entity';
|
||||
export * from './service.entity';
|
||||
102
src/modules/service-management/entities/order-item.entity.ts
Normal file
102
src/modules/service-management/entities/order-item.entity.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Order Item Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents line items (services or parts) in a service order.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ServiceOrder } from './service-order.entity';
|
||||
|
||||
export enum OrderItemType {
|
||||
SERVICE = 'service',
|
||||
PART = 'part',
|
||||
}
|
||||
|
||||
export enum OrderItemStatus {
|
||||
PENDING = 'pending',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
|
||||
@Entity({ name: 'order_items', schema: 'service_management' })
|
||||
@Index('idx_order_items_order', ['orderId'])
|
||||
export class OrderItem {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'order_id', type: 'uuid' })
|
||||
orderId: string;
|
||||
|
||||
// Type
|
||||
@Column({ name: 'item_type', type: 'varchar', length: 20 })
|
||||
itemType: OrderItemType;
|
||||
|
||||
// Optional references
|
||||
@Column({ name: 'service_id', type: 'uuid', nullable: true })
|
||||
serviceId?: string;
|
||||
|
||||
@Column({ name: 'part_id', type: 'uuid', nullable: true })
|
||||
partId?: string;
|
||||
|
||||
// Description
|
||||
@Column({ type: 'varchar', length: 500 })
|
||||
description: string;
|
||||
|
||||
// Quantities and prices
|
||||
@Column({ type: 'decimal', precision: 10, scale: 3, default: 1 })
|
||||
quantity: number;
|
||||
|
||||
@Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 })
|
||||
unitPrice: number;
|
||||
|
||||
@Column({ name: 'discount_pct', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPct: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||
subtotal: number;
|
||||
|
||||
// Status
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: OrderItemStatus.PENDING,
|
||||
})
|
||||
status: OrderItemStatus;
|
||||
|
||||
// For labor items
|
||||
@Column({ name: 'estimated_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
estimatedHours?: number;
|
||||
|
||||
@Column({ name: 'actual_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
actualHours?: number;
|
||||
|
||||
// Mechanic
|
||||
@Column({ name: 'performed_by', type: 'uuid', nullable: true })
|
||||
performedBy?: string;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'integer', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ServiceOrder, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order: ServiceOrder;
|
||||
}
|
||||
140
src/modules/service-management/entities/quote.entity.ts
Normal file
140
src/modules/service-management/entities/quote.entity.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Quote Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents service quotations for customers.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Diagnostic } from './diagnostic.entity';
|
||||
import { ServiceOrder } from './service-order.entity';
|
||||
|
||||
export enum QuoteStatus {
|
||||
DRAFT = 'draft',
|
||||
SENT = 'sent',
|
||||
VIEWED = 'viewed',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected',
|
||||
EXPIRED = 'expired',
|
||||
CONVERTED = 'converted',
|
||||
}
|
||||
|
||||
@Entity({ name: 'quotes', schema: 'service_management' })
|
||||
@Index('idx_quotes_tenant', ['tenantId'])
|
||||
@Index('idx_quotes_status', ['tenantId', 'status'])
|
||||
@Index('idx_quotes_customer', ['customerId'])
|
||||
export class Quote {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'quote_number', type: 'varchar', length: 20 })
|
||||
quoteNumber: string;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid' })
|
||||
customerId: string;
|
||||
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId: string;
|
||||
|
||||
@Column({ name: 'diagnostic_id', type: 'uuid', nullable: true })
|
||||
diagnosticId?: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: QuoteStatus.DRAFT,
|
||||
})
|
||||
status: QuoteStatus;
|
||||
|
||||
// Dates
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
|
||||
sentAt?: Date;
|
||||
|
||||
@Column({ name: 'viewed_at', type: 'timestamptz', nullable: true })
|
||||
viewedAt?: Date;
|
||||
|
||||
@Column({ name: 'responded_at', type: 'timestamptz', nullable: true })
|
||||
respondedAt?: Date;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt?: Date;
|
||||
|
||||
// Totals
|
||||
@Column({ name: 'labor_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
laborTotal: number;
|
||||
|
||||
@Column({ name: 'parts_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
partsTotal: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ name: 'discount_reason', type: 'varchar', length: 200, nullable: true })
|
||||
discountReason?: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
tax: number;
|
||||
|
||||
@Column({ name: 'grand_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
grandTotal: number;
|
||||
|
||||
@Column({ name: 'validity_days', type: 'integer', default: 15 })
|
||||
validityDays: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
terms?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
// Conversion to order
|
||||
@Column({ name: 'converted_order_id', type: 'uuid', nullable: true })
|
||||
convertedOrderId?: string;
|
||||
|
||||
// Digital approval
|
||||
@Column({ name: 'approved_by_name', type: 'varchar', length: 200, nullable: true })
|
||||
approvedByName?: string;
|
||||
|
||||
@Column({ name: 'approval_signature', type: 'text', nullable: true })
|
||||
approvalSignature?: string;
|
||||
|
||||
@Column({ name: 'approval_ip', type: 'inet', nullable: true })
|
||||
approvalIp?: string;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Diagnostic, { nullable: true })
|
||||
@JoinColumn({ name: 'diagnostic_id' })
|
||||
diagnostic?: Diagnostic;
|
||||
|
||||
@ManyToOne(() => ServiceOrder, { nullable: true })
|
||||
@JoinColumn({ name: 'converted_order_id' })
|
||||
convertedOrder?: ServiceOrder;
|
||||
|
||||
// @OneToMany(() => QuoteItem, item => item.quote)
|
||||
// items: QuoteItem[];
|
||||
}
|
||||
161
src/modules/service-management/entities/service-order.entity.ts
Normal file
161
src/modules/service-management/entities/service-order.entity.ts
Normal file
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Service Order Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents a vehicle service order in the workshop.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
Check,
|
||||
} from 'typeorm';
|
||||
|
||||
// Status values for service orders
|
||||
export enum ServiceOrderStatus {
|
||||
RECEIVED = 'received',
|
||||
DIAGNOSED = 'diagnosed',
|
||||
QUOTED = 'quoted',
|
||||
APPROVED = 'approved',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
WAITING_PARTS = 'waiting_parts',
|
||||
COMPLETED = 'completed',
|
||||
DELIVERED = 'delivered',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export enum ServiceOrderPriority {
|
||||
LOW = 'low',
|
||||
NORMAL = 'normal',
|
||||
HIGH = 'high',
|
||||
URGENT = 'urgent',
|
||||
}
|
||||
|
||||
@Entity({ name: 'service_orders', schema: 'service_management' })
|
||||
@Index('idx_orders_tenant', ['tenantId'])
|
||||
@Index('idx_orders_status', ['tenantId', 'status'])
|
||||
@Index('idx_orders_vehicle', ['vehicleId'])
|
||||
@Index('idx_orders_customer', ['customerId'])
|
||||
@Index('idx_orders_assigned', ['assignedTo'])
|
||||
@Check('chk_odometer', '"odometer_out" IS NULL OR "odometer_out" >= "odometer_in"')
|
||||
export class ServiceOrder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
// Identification
|
||||
@Column({ name: 'order_number', type: 'varchar', length: 20 })
|
||||
orderNumber: string;
|
||||
|
||||
// Relations
|
||||
@Column({ name: 'customer_id', type: 'uuid' })
|
||||
customerId: string;
|
||||
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId: string;
|
||||
|
||||
@Column({ name: 'quote_id', type: 'uuid', nullable: true })
|
||||
quoteId?: string;
|
||||
|
||||
// Assignment
|
||||
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
|
||||
assignedTo?: string;
|
||||
|
||||
@Column({ name: 'bay_id', type: 'uuid', nullable: true })
|
||||
bayId?: string;
|
||||
|
||||
// Status
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
default: ServiceOrderStatus.RECEIVED,
|
||||
})
|
||||
status: ServiceOrderStatus;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: ServiceOrderPriority.NORMAL,
|
||||
})
|
||||
priority: ServiceOrderPriority;
|
||||
|
||||
// Dates
|
||||
@Column({ name: 'received_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||
receivedAt: Date;
|
||||
|
||||
@Column({ name: 'promised_at', type: 'timestamptz', nullable: true })
|
||||
promisedAt?: Date;
|
||||
|
||||
@Column({ name: 'started_at', type: 'timestamptz', nullable: true })
|
||||
startedAt?: Date;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date;
|
||||
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt?: Date;
|
||||
|
||||
// Odometer
|
||||
@Column({ name: 'odometer_in', type: 'integer', nullable: true })
|
||||
odometerIn?: number;
|
||||
|
||||
@Column({ name: 'odometer_out', type: 'integer', nullable: true })
|
||||
odometerOut?: number;
|
||||
|
||||
// Symptoms
|
||||
@Column({ name: 'customer_symptoms', type: 'text', nullable: true })
|
||||
customerSymptoms?: string;
|
||||
|
||||
// Totals
|
||||
@Column({ name: 'labor_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
laborTotal: number;
|
||||
|
||||
@Column({ name: 'parts_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
partsTotal: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPercent: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
tax: number;
|
||||
|
||||
@Column({ name: 'grand_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
grandTotal: number;
|
||||
|
||||
// Notes
|
||||
@Column({ name: 'internal_notes', type: 'text', nullable: true })
|
||||
internalNotes?: string;
|
||||
|
||||
@Column({ name: 'customer_notes', type: 'text', nullable: true })
|
||||
customerNotes?: string;
|
||||
|
||||
// Audit
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations (to be added when other entities are defined)
|
||||
// @ManyToOne(() => Vehicle, vehicle => vehicle.serviceOrders)
|
||||
// @JoinColumn({ name: 'vehicle_id' })
|
||||
// vehicle: Vehicle;
|
||||
|
||||
// @OneToMany(() => OrderItem, item => item.order)
|
||||
// items: OrderItem[];
|
||||
}
|
||||
63
src/modules/service-management/entities/service.entity.ts
Normal file
63
src/modules/service-management/entities/service.entity.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Service Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents service catalog items.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'services', schema: 'service_management' })
|
||||
@Index('idx_services_tenant', ['tenantId'])
|
||||
@Index('idx_services_category', ['categoryId'])
|
||||
@Index('idx_services_code', ['code'])
|
||||
export class Service {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ name: 'category_id', type: 'uuid', nullable: true })
|
||||
categoryId?: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||
price: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||
cost?: number;
|
||||
|
||||
@Column({ name: 'estimated_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
estimatedHours?: 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;
|
||||
|
||||
// @ManyToOne(() => ServiceCategory)
|
||||
// @JoinColumn({ name: 'category_id' })
|
||||
// category?: ServiceCategory;
|
||||
}
|
||||
77
src/modules/service-management/entities/work-bay.entity.ts
Normal file
77
src/modules/service-management/entities/work-bay.entity.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Work Bay Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents work bays in the workshop.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum BayType {
|
||||
GENERAL = 'general',
|
||||
DIAGNOSTIC = 'diagnostic',
|
||||
HEAVY_DUTY = 'heavy_duty',
|
||||
QUICK_SERVICE = 'quick_service',
|
||||
}
|
||||
|
||||
export enum BayStatus {
|
||||
AVAILABLE = 'available',
|
||||
OCCUPIED = 'occupied',
|
||||
MAINTENANCE = 'maintenance',
|
||||
}
|
||||
|
||||
@Entity({ name: 'work_bays', schema: 'service_management' })
|
||||
@Index('idx_bays_tenant', ['tenantId'])
|
||||
@Index('idx_bays_status', ['tenantId', 'status'])
|
||||
export class WorkBay {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ name: 'bay_type', type: 'varchar', length: 50, nullable: true })
|
||||
bayType?: BayType;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: BayStatus.AVAILABLE,
|
||||
})
|
||||
status: BayStatus;
|
||||
|
||||
@Column({ name: 'current_order_id', type: 'uuid', nullable: true })
|
||||
currentOrderId?: string;
|
||||
|
||||
// Capacity
|
||||
@Column({ name: 'max_weight', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
maxWeight?: number;
|
||||
|
||||
@Column({ name: 'has_lift', type: 'boolean', default: false })
|
||||
hasLift: boolean;
|
||||
|
||||
@Column({ name: 'has_pit', type: 'boolean', default: false })
|
||||
hasPit: boolean;
|
||||
|
||||
@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;
|
||||
}
|
||||
22
src/modules/service-management/index.ts
Normal file
22
src/modules/service-management/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Service Management Module
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*/
|
||||
|
||||
// Entities
|
||||
export { ServiceOrder, ServiceOrderStatus, ServiceOrderPriority } from './entities/service-order.entity';
|
||||
export { OrderItem, OrderItemType, OrderItemStatus } from './entities/order-item.entity';
|
||||
export { Diagnostic, DiagnosticType, DiagnosticResult } from './entities/diagnostic.entity';
|
||||
export { Quote, QuoteStatus } from './entities/quote.entity';
|
||||
export { WorkBay, BayStatus, BayType } from './entities/work-bay.entity';
|
||||
export { Service } from './entities/service.entity';
|
||||
|
||||
// Services
|
||||
export { ServiceOrderService, CreateServiceOrderDto, UpdateServiceOrderDto, ServiceOrderFilters } from './services/service-order.service';
|
||||
export { DiagnosticService, CreateDiagnosticDto, DiagnosticItemDto, DiagnosticRecommendationDto } from './services/diagnostic.service';
|
||||
export { QuoteService, CreateQuoteDto, QuoteItemDto, ApplyDiscountDto } from './services/quote.service';
|
||||
|
||||
// Controllers
|
||||
export { createServiceOrderController } from './controllers/service-order.controller';
|
||||
export { createDiagnosticController } from './controllers/diagnostic.controller';
|
||||
export { createQuoteController } from './controllers/quote.controller';
|
||||
290
src/modules/service-management/services/diagnostic.service.ts
Normal file
290
src/modules/service-management/services/diagnostic.service.ts
Normal file
@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Diagnostic Service
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for vehicle diagnostics.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import {
|
||||
Diagnostic,
|
||||
DiagnosticType,
|
||||
DiagnosticResult,
|
||||
} from '../entities/diagnostic.entity';
|
||||
|
||||
// DTOs
|
||||
export interface CreateDiagnosticDto {
|
||||
vehicleId: string;
|
||||
orderId?: string;
|
||||
diagnosticType: DiagnosticType;
|
||||
equipment?: string;
|
||||
performedBy?: string;
|
||||
summary?: string;
|
||||
rawData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DiagnosticItemDto {
|
||||
itemType: 'dtc_code' | 'test_result' | 'measurement' | 'observation';
|
||||
code?: string;
|
||||
description?: string;
|
||||
severity?: 'critical' | 'warning' | 'info';
|
||||
parameter?: string;
|
||||
value?: number;
|
||||
unit?: string;
|
||||
minRef?: number;
|
||||
maxRef?: number;
|
||||
status?: 'ok' | 'warning' | 'fail' | 'no_reference';
|
||||
component?: string;
|
||||
cylinder?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticRecommendationDto {
|
||||
description: string;
|
||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||
urgency: 'immediate' | 'soon' | 'scheduled' | 'preventive';
|
||||
suggestedServiceId?: string;
|
||||
estimatedCost?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class DiagnosticService {
|
||||
private diagnosticRepository: Repository<Diagnostic>;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.diagnosticRepository = dataSource.getRepository(Diagnostic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new diagnostic
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateDiagnosticDto): Promise<Diagnostic> {
|
||||
const diagnostic = this.diagnosticRepository.create({
|
||||
tenantId,
|
||||
vehicleId: dto.vehicleId,
|
||||
orderId: dto.orderId,
|
||||
diagnosticType: dto.diagnosticType,
|
||||
equipment: dto.equipment,
|
||||
performedBy: dto.performedBy,
|
||||
summary: dto.summary,
|
||||
rawData: dto.rawData,
|
||||
performedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.diagnosticRepository.save(diagnostic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find diagnostic by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<Diagnostic | null> {
|
||||
return this.diagnosticRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find diagnostics by vehicle
|
||||
*/
|
||||
async findByVehicle(tenantId: string, vehicleId: string): Promise<Diagnostic[]> {
|
||||
return this.diagnosticRepository.find({
|
||||
where: { tenantId, vehicleId },
|
||||
order: { performedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find diagnostics by order
|
||||
*/
|
||||
async findByOrder(tenantId: string, orderId: string): Promise<Diagnostic[]> {
|
||||
return this.diagnosticRepository.find({
|
||||
where: { tenantId, orderId },
|
||||
order: { performedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update diagnostic result
|
||||
*/
|
||||
async updateResult(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
result: DiagnosticResult,
|
||||
summary?: string
|
||||
): Promise<Diagnostic | null> {
|
||||
const diagnostic = await this.findById(tenantId, id);
|
||||
if (!diagnostic) return null;
|
||||
|
||||
diagnostic.result = result;
|
||||
if (summary) diagnostic.summary = summary;
|
||||
|
||||
return this.diagnosticRepository.save(diagnostic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostic statistics for a vehicle
|
||||
*/
|
||||
async getVehicleStats(tenantId: string, vehicleId: string): Promise<{
|
||||
totalDiagnostics: number;
|
||||
lastDiagnosticDate: Date | null;
|
||||
diagnosticsByType: Record<DiagnosticType, number>;
|
||||
issuesFound: number;
|
||||
}> {
|
||||
const diagnostics = await this.findByVehicle(tenantId, vehicleId);
|
||||
|
||||
const diagnosticsByType: Record<DiagnosticType, number> = {
|
||||
[DiagnosticType.SCANNER]: 0,
|
||||
[DiagnosticType.INJECTOR_TEST]: 0,
|
||||
[DiagnosticType.PUMP_TEST]: 0,
|
||||
[DiagnosticType.COMPRESSION]: 0,
|
||||
[DiagnosticType.TURBO_TEST]: 0,
|
||||
[DiagnosticType.OTHER]: 0,
|
||||
};
|
||||
|
||||
let issuesFound = 0;
|
||||
|
||||
for (const diag of diagnostics) {
|
||||
diagnosticsByType[diag.diagnosticType]++;
|
||||
if (diag.result === DiagnosticResult.FAIL || diag.result === DiagnosticResult.NEEDS_ATTENTION) {
|
||||
issuesFound++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalDiagnostics: diagnostics.length,
|
||||
lastDiagnosticDate: diagnostics.length > 0 ? diagnostics[0].performedAt : null,
|
||||
diagnosticsByType,
|
||||
issuesFound,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse DTC codes from scanner data
|
||||
*/
|
||||
parseDTCCodes(rawData: Record<string, unknown>): DiagnosticItemDto[] {
|
||||
const items: DiagnosticItemDto[] = [];
|
||||
|
||||
// Handle common scanner data formats
|
||||
const dtcCodes = rawData.dtc_codes || rawData.codes || rawData.faults || [];
|
||||
|
||||
if (Array.isArray(dtcCodes)) {
|
||||
for (const code of dtcCodes) {
|
||||
if (typeof code === 'string') {
|
||||
items.push({
|
||||
itemType: 'dtc_code',
|
||||
code,
|
||||
description: this.getDTCDescription(code),
|
||||
severity: this.getDTCSeverity(code),
|
||||
});
|
||||
} else if (typeof code === 'object' && code !== null) {
|
||||
items.push({
|
||||
itemType: 'dtc_code',
|
||||
code: code.code || code.id,
|
||||
description: code.description || code.message || this.getDTCDescription(code.code),
|
||||
severity: code.severity || this.getDTCSeverity(code.code),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DTC code description (simplified lookup)
|
||||
*/
|
||||
private getDTCDescription(code: string): string {
|
||||
// Common diesel DTC codes
|
||||
const descriptions: Record<string, string> = {
|
||||
'P0087': 'Fuel Rail/System Pressure - Too Low',
|
||||
'P0088': 'Fuel Rail/System Pressure - Too High',
|
||||
'P0093': 'Fuel System Leak Detected - Large Leak',
|
||||
'P0100': 'Mass Air Flow Circuit Malfunction',
|
||||
'P0101': 'Mass Air Flow Circuit Range/Performance',
|
||||
'P0102': 'Mass Air Flow Circuit Low',
|
||||
'P0103': 'Mass Air Flow Circuit High',
|
||||
'P0201': 'Injector Circuit/Open - Cylinder 1',
|
||||
'P0202': 'Injector Circuit/Open - Cylinder 2',
|
||||
'P0203': 'Injector Circuit/Open - Cylinder 3',
|
||||
'P0204': 'Injector Circuit/Open - Cylinder 4',
|
||||
'P0205': 'Injector Circuit/Open - Cylinder 5',
|
||||
'P0206': 'Injector Circuit/Open - Cylinder 6',
|
||||
'P0234': 'Turbocharger/Supercharger Overboost Condition',
|
||||
'P0299': 'Turbocharger/Supercharger Underboost',
|
||||
'P0401': 'Exhaust Gas Recirculation Flow Insufficient',
|
||||
'P0402': 'Exhaust Gas Recirculation Flow Excessive',
|
||||
'P0404': 'Exhaust Gas Recirculation Circuit Range/Performance',
|
||||
'P0405': 'Exhaust Gas Recirculation Sensor A Circuit Low',
|
||||
'P2002': 'Diesel Particulate Filter Efficiency Below Threshold',
|
||||
'P2003': 'Diesel Particulate Filter Efficiency Below Threshold Bank 2',
|
||||
'P242F': 'Diesel Particulate Filter Restriction - Ash Accumulation',
|
||||
};
|
||||
|
||||
return descriptions[code] || `Unknown code: ${code}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine DTC severity
|
||||
*/
|
||||
private getDTCSeverity(code: string): 'critical' | 'warning' | 'info' {
|
||||
// P0xxx codes starting with certain numbers are more critical
|
||||
if (code.startsWith('P0087') || code.startsWith('P0088') || code.startsWith('P0093')) {
|
||||
return 'critical'; // Fuel system issues
|
||||
}
|
||||
if (code.startsWith('P02')) {
|
||||
return 'critical'; // Injector issues
|
||||
}
|
||||
if (code.startsWith('P0234') || code.startsWith('P0299')) {
|
||||
return 'warning'; // Turbo issues
|
||||
}
|
||||
if (code.startsWith('P04')) {
|
||||
return 'warning'; // EGR issues
|
||||
}
|
||||
if (code.startsWith('P2')) {
|
||||
return 'warning'; // DPF issues
|
||||
}
|
||||
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze injector test results
|
||||
*/
|
||||
analyzeInjectorTest(rawData: Record<string, unknown>): DiagnosticItemDto[] {
|
||||
const items: DiagnosticItemDto[] = [];
|
||||
const injectors = rawData.injectors || rawData.cylinders || [];
|
||||
|
||||
if (Array.isArray(injectors)) {
|
||||
for (let i = 0; i < injectors.length; i++) {
|
||||
const injector = injectors[i];
|
||||
if (typeof injector === 'object' && injector !== null) {
|
||||
// Return quantity test
|
||||
if (injector.return_qty !== undefined) {
|
||||
items.push({
|
||||
itemType: 'measurement',
|
||||
parameter: 'Return Quantity',
|
||||
value: injector.return_qty,
|
||||
unit: 'ml/min',
|
||||
minRef: 0,
|
||||
maxRef: 50, // Typical max for healthy injector
|
||||
status: injector.return_qty > 50 ? 'fail' : 'ok',
|
||||
cylinder: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Spray pattern
|
||||
if (injector.spray_pattern !== undefined) {
|
||||
items.push({
|
||||
itemType: 'observation',
|
||||
description: `Spray pattern: ${injector.spray_pattern}`,
|
||||
status: injector.spray_pattern === 'good' ? 'ok' : 'warning',
|
||||
cylinder: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
401
src/modules/service-management/services/quote.service.ts
Normal file
401
src/modules/service-management/services/quote.service.ts
Normal file
@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Quote Service
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for quotations management.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Quote, QuoteStatus } from '../entities/quote.entity';
|
||||
import { ServiceOrder, ServiceOrderStatus } from '../entities/service-order.entity';
|
||||
|
||||
// DTOs
|
||||
export interface CreateQuoteDto {
|
||||
customerId: string;
|
||||
vehicleId: string;
|
||||
diagnosticId?: string;
|
||||
validityDays?: number;
|
||||
terms?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface QuoteItemDto {
|
||||
itemType: 'service' | 'part';
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discountPct?: number;
|
||||
serviceId?: string;
|
||||
partId?: string;
|
||||
}
|
||||
|
||||
export interface ApplyDiscountDto {
|
||||
discountPercent?: number;
|
||||
discountAmount?: number;
|
||||
discountReason?: string;
|
||||
}
|
||||
|
||||
export class QuoteService {
|
||||
private quoteRepository: Repository<Quote>;
|
||||
private orderRepository: Repository<ServiceOrder>;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.quoteRepository = dataSource.getRepository(Quote);
|
||||
this.orderRepository = dataSource.getRepository(ServiceOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next quote number for tenant
|
||||
*/
|
||||
private async generateQuoteNumber(tenantId: string): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `COT-${year}-`;
|
||||
|
||||
const lastQuote = await this.quoteRepository.findOne({
|
||||
where: { tenantId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
let sequence = 1;
|
||||
if (lastQuote?.quoteNumber?.startsWith(prefix)) {
|
||||
const lastSeq = parseInt(lastQuote.quoteNumber.replace(prefix, ''), 10);
|
||||
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${sequence.toString().padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new quote
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateQuoteDto, userId?: string): Promise<Quote> {
|
||||
const quoteNumber = await this.generateQuoteNumber(tenantId);
|
||||
const validityDays = dto.validityDays || 15;
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + validityDays);
|
||||
|
||||
const quote = this.quoteRepository.create({
|
||||
tenantId,
|
||||
quoteNumber,
|
||||
customerId: dto.customerId,
|
||||
vehicleId: dto.vehicleId,
|
||||
diagnosticId: dto.diagnosticId,
|
||||
status: QuoteStatus.DRAFT,
|
||||
validityDays,
|
||||
expiresAt,
|
||||
terms: dto.terms,
|
||||
notes: dto.notes,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return this.quoteRepository.save(quote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find quote by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<Quote | null> {
|
||||
return this.quoteRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find quote by number
|
||||
*/
|
||||
async findByNumber(tenantId: string, quoteNumber: string): Promise<Quote | null> {
|
||||
return this.quoteRepository.findOne({
|
||||
where: { tenantId, quoteNumber },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List quotes with filters
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: {
|
||||
status?: QuoteStatus;
|
||||
customerId?: string;
|
||||
vehicleId?: string;
|
||||
fromDate?: Date;
|
||||
toDate?: Date;
|
||||
} = {},
|
||||
pagination = { page: 1, limit: 20 }
|
||||
) {
|
||||
const queryBuilder = this.quoteRepository.createQueryBuilder('quote')
|
||||
.where('quote.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('quote.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.customerId) {
|
||||
queryBuilder.andWhere('quote.customer_id = :customerId', { customerId: filters.customerId });
|
||||
}
|
||||
if (filters.vehicleId) {
|
||||
queryBuilder.andWhere('quote.vehicle_id = :vehicleId', { vehicleId: filters.vehicleId });
|
||||
}
|
||||
if (filters.fromDate) {
|
||||
queryBuilder.andWhere('quote.created_at >= :fromDate', { fromDate: filters.fromDate });
|
||||
}
|
||||
if (filters.toDate) {
|
||||
queryBuilder.andWhere('quote.created_at <= :toDate', { toDate: filters.toDate });
|
||||
}
|
||||
|
||||
const skip = (pagination.page - 1) * pagination.limit;
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('quote.created_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(pagination.limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
totalPages: Math.ceil(total / pagination.limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send quote to customer
|
||||
*/
|
||||
async send(tenantId: string, id: string, channel: 'email' | 'whatsapp'): Promise<Quote | null> {
|
||||
const quote = await this.findById(tenantId, id);
|
||||
if (!quote) return null;
|
||||
|
||||
if (quote.status !== QuoteStatus.DRAFT) {
|
||||
throw new Error('Quote has already been sent');
|
||||
}
|
||||
|
||||
quote.status = QuoteStatus.SENT;
|
||||
quote.sentAt = new Date();
|
||||
|
||||
// TODO: Integrate with notification service
|
||||
// await notificationService.sendQuote(quote, channel);
|
||||
|
||||
return this.quoteRepository.save(quote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark quote as viewed
|
||||
*/
|
||||
async markViewed(tenantId: string, id: string): Promise<Quote | null> {
|
||||
const quote = await this.findById(tenantId, id);
|
||||
if (!quote) return null;
|
||||
|
||||
if (!quote.viewedAt) {
|
||||
quote.viewedAt = new Date();
|
||||
if (quote.status === QuoteStatus.SENT) {
|
||||
quote.status = QuoteStatus.VIEWED;
|
||||
}
|
||||
return this.quoteRepository.save(quote);
|
||||
}
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve quote (customer action)
|
||||
*/
|
||||
async approve(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
approvalData: {
|
||||
approvedByName: string;
|
||||
approvalSignature?: string;
|
||||
approvalIp?: string;
|
||||
}
|
||||
): Promise<Quote | null> {
|
||||
const quote = await this.findById(tenantId, id);
|
||||
if (!quote) return null;
|
||||
|
||||
if (quote.status === QuoteStatus.EXPIRED) {
|
||||
throw new Error('Quote has expired');
|
||||
}
|
||||
if (quote.status === QuoteStatus.REJECTED) {
|
||||
throw new Error('Quote was rejected');
|
||||
}
|
||||
if (quote.status === QuoteStatus.APPROVED || quote.status === QuoteStatus.CONVERTED) {
|
||||
throw new Error('Quote has already been approved');
|
||||
}
|
||||
|
||||
quote.status = QuoteStatus.APPROVED;
|
||||
quote.respondedAt = new Date();
|
||||
quote.approvedByName = approvalData.approvedByName;
|
||||
quote.approvalSignature = approvalData.approvalSignature;
|
||||
quote.approvalIp = approvalData.approvalIp;
|
||||
|
||||
return this.quoteRepository.save(quote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject quote
|
||||
*/
|
||||
async reject(tenantId: string, id: string, reason?: string): Promise<Quote | null> {
|
||||
const quote = await this.findById(tenantId, id);
|
||||
if (!quote) return null;
|
||||
|
||||
quote.status = QuoteStatus.REJECTED;
|
||||
quote.respondedAt = new Date();
|
||||
if (reason) {
|
||||
quote.notes = `${quote.notes || ''}\n\nRejection reason: ${reason}`.trim();
|
||||
}
|
||||
|
||||
return this.quoteRepository.save(quote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quote to service order
|
||||
*/
|
||||
async convertToOrder(tenantId: string, id: string, userId?: string): Promise<ServiceOrder | null> {
|
||||
const quote = await this.findById(tenantId, id);
|
||||
if (!quote) return null;
|
||||
|
||||
if (quote.status !== QuoteStatus.APPROVED) {
|
||||
throw new Error('Quote must be approved before conversion');
|
||||
}
|
||||
|
||||
// Generate order number
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `OS-${year}-`;
|
||||
const lastOrder = await this.orderRepository.findOne({
|
||||
where: { tenantId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
let sequence = 1;
|
||||
if (lastOrder?.orderNumber?.startsWith(prefix)) {
|
||||
const lastSeq = parseInt(lastOrder.orderNumber.replace(prefix, ''), 10);
|
||||
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
|
||||
}
|
||||
|
||||
const orderNumber = `${prefix}${sequence.toString().padStart(5, '0')}`;
|
||||
|
||||
// Create service order
|
||||
const order = this.orderRepository.create({
|
||||
tenantId,
|
||||
orderNumber,
|
||||
customerId: quote.customerId,
|
||||
vehicleId: quote.vehicleId,
|
||||
quoteId: quote.id,
|
||||
status: ServiceOrderStatus.APPROVED,
|
||||
laborTotal: quote.laborTotal,
|
||||
partsTotal: quote.partsTotal,
|
||||
discountAmount: quote.discountAmount,
|
||||
discountPercent: quote.discountPercent,
|
||||
tax: quote.tax,
|
||||
grandTotal: quote.grandTotal,
|
||||
customerNotes: quote.notes,
|
||||
createdBy: userId,
|
||||
receivedAt: new Date(),
|
||||
});
|
||||
|
||||
const savedOrder = await this.orderRepository.save(order);
|
||||
|
||||
// Update quote
|
||||
quote.status = QuoteStatus.CONVERTED;
|
||||
quote.convertedOrderId = savedOrder.id;
|
||||
await this.quoteRepository.save(quote);
|
||||
|
||||
return savedOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply discount to quote
|
||||
*/
|
||||
async applyDiscount(tenantId: string, id: string, dto: ApplyDiscountDto): Promise<Quote | null> {
|
||||
const quote = await this.findById(tenantId, id);
|
||||
if (!quote) return null;
|
||||
|
||||
if (dto.discountPercent !== undefined) {
|
||||
quote.discountPercent = dto.discountPercent;
|
||||
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
|
||||
quote.discountAmount = subtotal * (dto.discountPercent / 100);
|
||||
} else if (dto.discountAmount !== undefined) {
|
||||
quote.discountAmount = dto.discountAmount;
|
||||
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
|
||||
quote.discountPercent = subtotal > 0 ? (dto.discountAmount / subtotal) * 100 : 0;
|
||||
}
|
||||
|
||||
if (dto.discountReason) {
|
||||
quote.discountReason = dto.discountReason;
|
||||
}
|
||||
|
||||
// Recalculate totals
|
||||
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
|
||||
const taxableAmount = subtotal - Number(quote.discountAmount);
|
||||
quote.tax = taxableAmount * 0.16; // 16% IVA
|
||||
quote.grandTotal = taxableAmount + quote.tax;
|
||||
|
||||
return this.quoteRepository.save(quote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and update expired quotes
|
||||
*/
|
||||
async updateExpiredQuotes(tenantId: string): Promise<number> {
|
||||
const result = await this.quoteRepository
|
||||
.createQueryBuilder()
|
||||
.update(Quote)
|
||||
.set({ status: QuoteStatus.EXPIRED })
|
||||
.where('tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('status IN (:...statuses)', {
|
||||
statuses: [QuoteStatus.DRAFT, QuoteStatus.SENT, QuoteStatus.VIEWED],
|
||||
})
|
||||
.andWhere('expires_at < :now', { now: new Date() })
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quote statistics
|
||||
*/
|
||||
async getStats(tenantId: string): Promise<{
|
||||
total: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
converted: number;
|
||||
conversionRate: number;
|
||||
averageValue: number;
|
||||
}> {
|
||||
const [total, pending, approved, rejected, converted, valueResult] = await Promise.all([
|
||||
this.quoteRepository.count({ where: { tenantId } }),
|
||||
this.quoteRepository.count({
|
||||
where: { tenantId, status: QuoteStatus.SENT },
|
||||
}),
|
||||
this.quoteRepository.count({
|
||||
where: { tenantId, status: QuoteStatus.APPROVED },
|
||||
}),
|
||||
this.quoteRepository.count({
|
||||
where: { tenantId, status: QuoteStatus.REJECTED },
|
||||
}),
|
||||
this.quoteRepository.count({
|
||||
where: { tenantId, status: QuoteStatus.CONVERTED },
|
||||
}),
|
||||
this.quoteRepository
|
||||
.createQueryBuilder('quote')
|
||||
.select('AVG(quote.grand_total)', 'avg')
|
||||
.where('quote.tenant_id = :tenantId', { tenantId })
|
||||
.getRawOne(),
|
||||
]);
|
||||
|
||||
const totalResponded = approved + rejected + converted;
|
||||
const conversionRate = totalResponded > 0 ? ((approved + converted) / totalResponded) * 100 : 0;
|
||||
|
||||
return {
|
||||
total,
|
||||
pending,
|
||||
approved,
|
||||
rejected,
|
||||
converted,
|
||||
conversionRate,
|
||||
averageValue: parseFloat(valueResult?.avg) || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
484
src/modules/service-management/services/service-order.service.ts
Normal file
484
src/modules/service-management/services/service-order.service.ts
Normal file
@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Service Order Service
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for service orders management.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource, FindOptionsWhere, ILike } from 'typeorm';
|
||||
import {
|
||||
ServiceOrder,
|
||||
ServiceOrderStatus,
|
||||
ServiceOrderPriority,
|
||||
} from '../entities/service-order.entity';
|
||||
import { OrderItem, OrderItemType, OrderItemStatus } from '../entities/order-item.entity';
|
||||
|
||||
// DTOs
|
||||
export interface CreateServiceOrderDto {
|
||||
customerId: string;
|
||||
vehicleId: string;
|
||||
customerSymptoms?: string;
|
||||
priority?: ServiceOrderPriority;
|
||||
promisedAt?: Date;
|
||||
assignedTo?: string;
|
||||
bayId?: string;
|
||||
odometerIn?: number;
|
||||
internalNotes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateServiceOrderDto {
|
||||
status?: ServiceOrderStatus;
|
||||
priority?: ServiceOrderPriority;
|
||||
assignedTo?: string;
|
||||
bayId?: string;
|
||||
promisedAt?: Date;
|
||||
odometerOut?: number;
|
||||
customerSymptoms?: string;
|
||||
internalNotes?: string;
|
||||
customerNotes?: string;
|
||||
}
|
||||
|
||||
export interface AddOrderItemDto {
|
||||
itemType: OrderItemType;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discountPct?: number;
|
||||
serviceId?: string;
|
||||
partId?: string;
|
||||
estimatedHours?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ServiceOrderFilters {
|
||||
status?: ServiceOrderStatus;
|
||||
priority?: ServiceOrderPriority;
|
||||
customerId?: string;
|
||||
vehicleId?: string;
|
||||
assignedTo?: string;
|
||||
bayId?: string;
|
||||
search?: string;
|
||||
fromDate?: Date;
|
||||
toDate?: Date;
|
||||
}
|
||||
|
||||
export interface PaginationOptions {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export class ServiceOrderService {
|
||||
private orderRepository: Repository<ServiceOrder>;
|
||||
private itemRepository: Repository<OrderItem>;
|
||||
|
||||
constructor(private dataSource: DataSource) {
|
||||
this.orderRepository = dataSource.getRepository(ServiceOrder);
|
||||
this.itemRepository = dataSource.getRepository(OrderItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next order number for tenant
|
||||
*/
|
||||
private async generateOrderNumber(tenantId: string): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `OS-${year}-`;
|
||||
|
||||
const lastOrder = await this.orderRepository.findOne({
|
||||
where: { tenantId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
let sequence = 1;
|
||||
if (lastOrder?.orderNumber?.startsWith(prefix)) {
|
||||
const lastSeq = parseInt(lastOrder.orderNumber.replace(prefix, ''), 10);
|
||||
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${sequence.toString().padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new service order
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateServiceOrderDto, userId?: string): Promise<ServiceOrder> {
|
||||
const orderNumber = await this.generateOrderNumber(tenantId);
|
||||
|
||||
const order = this.orderRepository.create({
|
||||
tenantId,
|
||||
orderNumber,
|
||||
customerId: dto.customerId,
|
||||
vehicleId: dto.vehicleId,
|
||||
customerSymptoms: dto.customerSymptoms,
|
||||
priority: dto.priority || ServiceOrderPriority.NORMAL,
|
||||
status: ServiceOrderStatus.RECEIVED,
|
||||
promisedAt: dto.promisedAt,
|
||||
assignedTo: dto.assignedTo,
|
||||
bayId: dto.bayId,
|
||||
odometerIn: dto.odometerIn,
|
||||
internalNotes: dto.internalNotes,
|
||||
createdBy: userId,
|
||||
receivedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.orderRepository.save(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find order by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<ServiceOrder | null> {
|
||||
return this.orderRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find order by order number
|
||||
*/
|
||||
async findByOrderNumber(tenantId: string, orderNumber: string): Promise<ServiceOrder | null> {
|
||||
return this.orderRepository.findOne({
|
||||
where: { tenantId, orderNumber },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List orders with filters and pagination
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: ServiceOrderFilters = {},
|
||||
pagination: PaginationOptions = { page: 1, limit: 20 }
|
||||
): Promise<PaginatedResult<ServiceOrder>> {
|
||||
const where: FindOptionsWhere<ServiceOrder> = { tenantId };
|
||||
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.priority) where.priority = filters.priority;
|
||||
if (filters.customerId) where.customerId = filters.customerId;
|
||||
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
|
||||
if (filters.assignedTo) where.assignedTo = filters.assignedTo;
|
||||
if (filters.bayId) where.bayId = filters.bayId;
|
||||
|
||||
const queryBuilder = this.orderRepository.createQueryBuilder('order')
|
||||
.where('order.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('order.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.priority) {
|
||||
queryBuilder.andWhere('order.priority = :priority', { priority: filters.priority });
|
||||
}
|
||||
if (filters.customerId) {
|
||||
queryBuilder.andWhere('order.customer_id = :customerId', { customerId: filters.customerId });
|
||||
}
|
||||
if (filters.vehicleId) {
|
||||
queryBuilder.andWhere('order.vehicle_id = :vehicleId', { vehicleId: filters.vehicleId });
|
||||
}
|
||||
if (filters.assignedTo) {
|
||||
queryBuilder.andWhere('order.assigned_to = :assignedTo', { assignedTo: filters.assignedTo });
|
||||
}
|
||||
if (filters.fromDate) {
|
||||
queryBuilder.andWhere('order.received_at >= :fromDate', { fromDate: filters.fromDate });
|
||||
}
|
||||
if (filters.toDate) {
|
||||
queryBuilder.andWhere('order.received_at <= :toDate', { toDate: filters.toDate });
|
||||
}
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(order.order_number ILIKE :search OR order.customer_symptoms ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (pagination.page - 1) * pagination.limit;
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('order.received_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(pagination.limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
totalPages: Math.ceil(total / pagination.limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update service order
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateServiceOrderDto
|
||||
): Promise<ServiceOrder | null> {
|
||||
const order = await this.findById(tenantId, id);
|
||||
if (!order) return null;
|
||||
|
||||
// Handle status transitions
|
||||
if (dto.status && dto.status !== order.status) {
|
||||
this.validateStatusTransition(order.status, dto.status);
|
||||
this.applyStatusSideEffects(order, dto.status);
|
||||
}
|
||||
|
||||
Object.assign(order, dto);
|
||||
return this.orderRepository.save(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate status transition
|
||||
*/
|
||||
private validateStatusTransition(from: ServiceOrderStatus, to: ServiceOrderStatus): void {
|
||||
const validTransitions: Record<ServiceOrderStatus, ServiceOrderStatus[]> = {
|
||||
[ServiceOrderStatus.RECEIVED]: [ServiceOrderStatus.DIAGNOSED, ServiceOrderStatus.CANCELLED],
|
||||
[ServiceOrderStatus.DIAGNOSED]: [ServiceOrderStatus.QUOTED, ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED],
|
||||
[ServiceOrderStatus.QUOTED]: [ServiceOrderStatus.APPROVED, ServiceOrderStatus.CANCELLED],
|
||||
[ServiceOrderStatus.APPROVED]: [ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED],
|
||||
[ServiceOrderStatus.IN_PROGRESS]: [ServiceOrderStatus.WAITING_PARTS, ServiceOrderStatus.COMPLETED, ServiceOrderStatus.CANCELLED],
|
||||
[ServiceOrderStatus.WAITING_PARTS]: [ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED],
|
||||
[ServiceOrderStatus.COMPLETED]: [ServiceOrderStatus.DELIVERED],
|
||||
[ServiceOrderStatus.DELIVERED]: [],
|
||||
[ServiceOrderStatus.CANCELLED]: [],
|
||||
};
|
||||
|
||||
if (!validTransitions[from].includes(to)) {
|
||||
throw new Error(`Invalid status transition from ${from} to ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply side effects when status changes
|
||||
*/
|
||||
private applyStatusSideEffects(order: ServiceOrder, newStatus: ServiceOrderStatus): void {
|
||||
const now = new Date();
|
||||
|
||||
switch (newStatus) {
|
||||
case ServiceOrderStatus.IN_PROGRESS:
|
||||
if (!order.startedAt) order.startedAt = now;
|
||||
break;
|
||||
case ServiceOrderStatus.COMPLETED:
|
||||
order.completedAt = now;
|
||||
break;
|
||||
case ServiceOrderStatus.DELIVERED:
|
||||
order.deliveredAt = now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item to order
|
||||
*/
|
||||
async addItem(tenantId: string, orderId: string, dto: AddOrderItemDto): Promise<OrderItem | null> {
|
||||
const order = await this.findById(tenantId, orderId);
|
||||
if (!order) return null;
|
||||
|
||||
const subtotal = dto.quantity * dto.unitPrice * (1 - (dto.discountPct || 0) / 100);
|
||||
|
||||
const item = this.itemRepository.create({
|
||||
orderId,
|
||||
itemType: dto.itemType,
|
||||
description: dto.description,
|
||||
quantity: dto.quantity,
|
||||
unitPrice: dto.unitPrice,
|
||||
discountPct: dto.discountPct || 0,
|
||||
subtotal,
|
||||
serviceId: dto.serviceId,
|
||||
partId: dto.partId,
|
||||
estimatedHours: dto.estimatedHours,
|
||||
notes: dto.notes,
|
||||
status: OrderItemStatus.PENDING,
|
||||
});
|
||||
|
||||
const savedItem = await this.itemRepository.save(item);
|
||||
|
||||
// Recalculate totals
|
||||
await this.recalculateTotals(orderId);
|
||||
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order items
|
||||
*/
|
||||
async getItems(orderId: string): Promise<OrderItem[]> {
|
||||
return this.itemRepository.find({
|
||||
where: { orderId },
|
||||
order: { sortOrder: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order item
|
||||
*/
|
||||
async updateItem(
|
||||
itemId: string,
|
||||
dto: Partial<AddOrderItemDto>
|
||||
): Promise<OrderItem | null> {
|
||||
const item = await this.itemRepository.findOne({ where: { id: itemId } });
|
||||
if (!item) return null;
|
||||
|
||||
if (dto.quantity !== undefined || dto.unitPrice !== undefined || dto.discountPct !== undefined) {
|
||||
const quantity = dto.quantity ?? item.quantity;
|
||||
const unitPrice = dto.unitPrice ?? item.unitPrice;
|
||||
const discountPct = dto.discountPct ?? item.discountPct;
|
||||
item.subtotal = quantity * unitPrice * (1 - discountPct / 100);
|
||||
}
|
||||
|
||||
Object.assign(item, dto);
|
||||
const savedItem = await this.itemRepository.save(item);
|
||||
|
||||
// Recalculate totals
|
||||
await this.recalculateTotals(item.orderId);
|
||||
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove order item
|
||||
*/
|
||||
async removeItem(itemId: string): Promise<boolean> {
|
||||
const item = await this.itemRepository.findOne({ where: { id: itemId } });
|
||||
if (!item) return false;
|
||||
|
||||
const orderId = item.orderId;
|
||||
await this.itemRepository.remove(item);
|
||||
|
||||
// Recalculate totals
|
||||
await this.recalculateTotals(orderId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate order totals
|
||||
*/
|
||||
private async recalculateTotals(orderId: string): Promise<void> {
|
||||
const items = await this.getItems(orderId);
|
||||
|
||||
let laborTotal = 0;
|
||||
let partsTotal = 0;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.itemType === OrderItemType.SERVICE) {
|
||||
laborTotal += Number(item.subtotal);
|
||||
} else {
|
||||
partsTotal += Number(item.subtotal);
|
||||
}
|
||||
}
|
||||
|
||||
const order = await this.orderRepository.findOne({ where: { id: orderId } });
|
||||
if (!order) return;
|
||||
|
||||
order.laborTotal = laborTotal;
|
||||
order.partsTotal = partsTotal;
|
||||
|
||||
const subtotal = laborTotal + partsTotal;
|
||||
const discountAmount = subtotal * (Number(order.discountPercent) / 100);
|
||||
order.discountAmount = discountAmount;
|
||||
|
||||
const taxableAmount = subtotal - discountAmount;
|
||||
order.tax = taxableAmount * 0.16; // 16% IVA México
|
||||
|
||||
order.grandTotal = taxableAmount + order.tax;
|
||||
|
||||
await this.orderRepository.save(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders by status (for Kanban board)
|
||||
*/
|
||||
async getOrdersByStatus(tenantId: string): Promise<Record<ServiceOrderStatus, ServiceOrder[]>> {
|
||||
const orders = await this.orderRepository.find({
|
||||
where: { tenantId },
|
||||
order: { receivedAt: 'DESC' },
|
||||
});
|
||||
|
||||
const grouped: Record<ServiceOrderStatus, ServiceOrder[]> = {
|
||||
[ServiceOrderStatus.RECEIVED]: [],
|
||||
[ServiceOrderStatus.DIAGNOSED]: [],
|
||||
[ServiceOrderStatus.QUOTED]: [],
|
||||
[ServiceOrderStatus.APPROVED]: [],
|
||||
[ServiceOrderStatus.IN_PROGRESS]: [],
|
||||
[ServiceOrderStatus.WAITING_PARTS]: [],
|
||||
[ServiceOrderStatus.COMPLETED]: [],
|
||||
[ServiceOrderStatus.DELIVERED]: [],
|
||||
[ServiceOrderStatus.CANCELLED]: [],
|
||||
};
|
||||
|
||||
for (const order of orders) {
|
||||
grouped[order.status].push(order);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard statistics
|
||||
*/
|
||||
async getDashboardStats(tenantId: string): Promise<{
|
||||
totalOrders: number;
|
||||
pendingOrders: number;
|
||||
inProgressOrders: number;
|
||||
completedToday: number;
|
||||
totalRevenue: number;
|
||||
averageTicket: number;
|
||||
}> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [
|
||||
totalOrders,
|
||||
pendingOrders,
|
||||
inProgressOrders,
|
||||
completedToday,
|
||||
revenueResult,
|
||||
] = await Promise.all([
|
||||
this.orderRepository.count({ where: { tenantId } }),
|
||||
this.orderRepository.count({
|
||||
where: { tenantId, status: ServiceOrderStatus.RECEIVED },
|
||||
}),
|
||||
this.orderRepository.count({
|
||||
where: { tenantId, status: ServiceOrderStatus.IN_PROGRESS },
|
||||
}),
|
||||
this.orderRepository.createQueryBuilder('order')
|
||||
.where('order.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('order.status = :status', { status: ServiceOrderStatus.COMPLETED })
|
||||
.andWhere('order.completed_at >= :today', { today })
|
||||
.getCount(),
|
||||
this.orderRepository.createQueryBuilder('order')
|
||||
.select('SUM(order.grand_total)', 'total')
|
||||
.where('order.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('order.status IN (:...statuses)', {
|
||||
statuses: [ServiceOrderStatus.COMPLETED, ServiceOrderStatus.DELIVERED],
|
||||
})
|
||||
.getRawOne(),
|
||||
]);
|
||||
|
||||
const totalRevenue = parseFloat(revenueResult?.total) || 0;
|
||||
const completedCount = await this.orderRepository.count({
|
||||
where: {
|
||||
tenantId,
|
||||
status: ServiceOrderStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
totalOrders,
|
||||
pendingOrders,
|
||||
inProgressOrders,
|
||||
completedToday,
|
||||
totalRevenue,
|
||||
averageTicket: completedCount > 0 ? totalRevenue / completedCount : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
174
src/modules/vehicle-management/controllers/fleet.controller.ts
Normal file
174
src/modules/vehicle-management/controllers/fleet.controller.ts
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Fleet Controller
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* REST API endpoints for fleet management.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { FleetService } from '../services/fleet.service';
|
||||
|
||||
interface TenantRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createFleetController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new FleetService(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);
|
||||
|
||||
/**
|
||||
* Create a new fleet
|
||||
* POST /api/fleets
|
||||
*/
|
||||
router.post('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const fleet = await service.create(req.tenantId!, req.body);
|
||||
res.status(201).json(fleet);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List fleets
|
||||
* GET /api/fleets
|
||||
*/
|
||||
router.get('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
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!, pagination);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get active fleets
|
||||
* GET /api/fleets/active
|
||||
*/
|
||||
router.get('/active', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const fleets = await service.findActive(req.tenantId!);
|
||||
res.json(fleets);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single fleet
|
||||
* GET /api/fleets/:id
|
||||
*/
|
||||
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const fleet = await service.findById(req.tenantId!, req.params.id);
|
||||
if (!fleet) {
|
||||
return res.status(404).json({ error: 'Fleet not found' });
|
||||
}
|
||||
res.json(fleet);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get fleet with statistics
|
||||
* GET /api/fleets/:id/stats
|
||||
*/
|
||||
router.get('/:id/stats', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const result = await service.getFleetWithStats(req.tenantId!, req.params.id);
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Fleet not found' });
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update fleet
|
||||
* PATCH /api/fleets/:id
|
||||
*/
|
||||
router.patch('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const fleet = await service.update(req.tenantId!, req.params.id, req.body);
|
||||
if (!fleet) {
|
||||
return res.status(404).json({ error: 'Fleet not found' });
|
||||
}
|
||||
res.json(fleet);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deactivate fleet
|
||||
* DELETE /api/fleets/:id
|
||||
*/
|
||||
router.delete('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.deactivate(req.tenantId!, req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Fleet not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Add vehicle to fleet
|
||||
* POST /api/fleets/:id/vehicles/:vehicleId
|
||||
*/
|
||||
router.post('/:id/vehicles/:vehicleId', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.addVehicle(req.tenantId!, req.params.id, req.params.vehicleId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Fleet or vehicle not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove vehicle from fleet
|
||||
* DELETE /api/fleets/:id/vehicles/:vehicleId
|
||||
*/
|
||||
router.delete('/:id/vehicles/:vehicleId', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.removeVehicle(req.tenantId!, req.params.id, req.params.vehicleId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Vehicle not found in fleet' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
238
src/modules/vehicle-management/controllers/vehicle.controller.ts
Normal file
238
src/modules/vehicle-management/controllers/vehicle.controller.ts
Normal file
@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Vehicle Controller
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* REST API endpoints for vehicle management.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { VehicleService, VehicleFilters } from '../services/vehicle.service';
|
||||
import { VehicleType, VehicleStatus } from '../entities/vehicle.entity';
|
||||
|
||||
interface TenantRequest extends Request {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createVehicleController(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
const service = new VehicleService(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);
|
||||
|
||||
/**
|
||||
* Create a new vehicle
|
||||
* POST /api/vehicles
|
||||
*/
|
||||
router.post('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const vehicle = await service.create(req.tenantId!, req.body);
|
||||
res.status(201).json(vehicle);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List vehicles with filters
|
||||
* GET /api/vehicles
|
||||
*/
|
||||
router.get('/', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const filters: VehicleFilters = {
|
||||
customerId: req.query.customerId as string,
|
||||
fleetId: req.query.fleetId as string,
|
||||
make: req.query.make as string,
|
||||
model: req.query.model as string,
|
||||
year: req.query.year ? parseInt(req.query.year as string, 10) : undefined,
|
||||
vehicleType: req.query.vehicleType as VehicleType,
|
||||
search: req.query.search as string,
|
||||
status: req.query.status as VehicleStatus,
|
||||
};
|
||||
|
||||
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 vehicle statistics
|
||||
* GET /api/vehicles/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 unique makes
|
||||
* GET /api/vehicles/makes
|
||||
*/
|
||||
router.get('/makes', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const makes = await service.getMakes(req.tenantId!);
|
||||
res.json(makes);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get models for a make
|
||||
* GET /api/vehicles/makes/:make/models
|
||||
*/
|
||||
router.get('/makes/:make/models', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const models = await service.getModels(req.tenantId!, req.params.make);
|
||||
res.json(models);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single vehicle
|
||||
* GET /api/vehicles/:id
|
||||
*/
|
||||
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const vehicle = await service.findById(req.tenantId!, req.params.id);
|
||||
if (!vehicle) {
|
||||
return res.status(404).json({ error: 'Vehicle not found' });
|
||||
}
|
||||
res.json(vehicle);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get by plate number
|
||||
* GET /api/vehicles/plate/:licensePlate
|
||||
*/
|
||||
router.get('/plate/:licensePlate', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const vehicle = await service.findByPlate(req.tenantId!, req.params.licensePlate);
|
||||
if (!vehicle) {
|
||||
return res.status(404).json({ error: 'Vehicle not found' });
|
||||
}
|
||||
res.json(vehicle);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get by VIN
|
||||
* GET /api/vehicles/vin/:vin
|
||||
*/
|
||||
router.get('/vin/:vin', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const vehicle = await service.findByVin(req.tenantId!, req.params.vin);
|
||||
if (!vehicle) {
|
||||
return res.status(404).json({ error: 'Vehicle not found' });
|
||||
}
|
||||
res.json(vehicle);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get customer's vehicles
|
||||
* GET /api/vehicles/customer/:customerId
|
||||
*/
|
||||
router.get('/customer/:customerId', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const vehicles = await service.findByCustomer(req.tenantId!, req.params.customerId);
|
||||
res.json(vehicles);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get fleet's vehicles
|
||||
* GET /api/vehicles/fleet/:fleetId
|
||||
*/
|
||||
router.get('/fleet/:fleetId', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const vehicles = await service.findByFleet(req.tenantId!, req.params.fleetId);
|
||||
res.json(vehicles);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update vehicle
|
||||
* PATCH /api/vehicles/:id
|
||||
*/
|
||||
router.patch('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const vehicle = await service.update(req.tenantId!, req.params.id, req.body);
|
||||
if (!vehicle) {
|
||||
return res.status(404).json({ error: 'Vehicle not found' });
|
||||
}
|
||||
res.json(vehicle);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update odometer
|
||||
* PATCH /api/vehicles/:id/odometer
|
||||
*/
|
||||
router.patch('/:id/odometer', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const vehicle = await service.updateOdometer(req.tenantId!, req.params.id, req.body.odometer);
|
||||
if (!vehicle) {
|
||||
return res.status(404).json({ error: 'Vehicle not found' });
|
||||
}
|
||||
res.json(vehicle);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deactivate vehicle
|
||||
* DELETE /api/vehicles/:id
|
||||
*/
|
||||
router.delete('/:id', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const success = await service.deactivate(req.tenantId!, req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Vehicle not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Engine Catalog Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Global catalog of diesel engine models.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
Check,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'engine_catalog', schema: 'vehicle_management' })
|
||||
@Check('chk_horsepower', '"horsepower_max" >= "horsepower_min"')
|
||||
@Check('chk_years', '"year_end" IS NULL OR "year_end" >= "year_start"')
|
||||
export class EngineCatalog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
make: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
model: string;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
cylinders?: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
displacement?: number;
|
||||
|
||||
@Column({ name: 'fuel_type', type: 'varchar', length: 20, default: 'diesel' })
|
||||
fuelType: string;
|
||||
|
||||
@Column({ name: 'horsepower_min', type: 'integer', nullable: true })
|
||||
horsepowerMin?: number;
|
||||
|
||||
@Column({ name: 'horsepower_max', type: 'integer', nullable: true })
|
||||
horsepowerMax?: number;
|
||||
|
||||
@Column({ name: 'torque_max', type: 'integer', nullable: true })
|
||||
torqueMax?: number;
|
||||
|
||||
@Column({ name: 'injection_system', type: 'varchar', length: 50, nullable: true })
|
||||
injectionSystem?: string;
|
||||
|
||||
@Column({ name: 'year_start', type: 'integer', nullable: true })
|
||||
yearStart?: number;
|
||||
|
||||
@Column({ name: 'year_end', type: 'integer', nullable: true })
|
||||
yearEnd?: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
76
src/modules/vehicle-management/entities/fleet.entity.ts
Normal file
76
src/modules/vehicle-management/entities/fleet.entity.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Fleet Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents vehicle fleets for commercial customers.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Vehicle } from './vehicle.entity';
|
||||
|
||||
@Entity({ name: 'fleets', schema: 'vehicle_management' })
|
||||
@Index('idx_fleets_tenant', ['tenantId'])
|
||||
@Index('idx_fleets_name', ['name'])
|
||||
export class Fleet {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
code?: string;
|
||||
|
||||
// Contact
|
||||
@Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true })
|
||||
contactName?: string;
|
||||
|
||||
@Column({ name: 'contact_email', type: 'varchar', length: 200, nullable: true })
|
||||
contactEmail?: string;
|
||||
|
||||
@Column({ name: 'contact_phone', type: 'varchar', length: 20, nullable: true })
|
||||
contactPhone?: string;
|
||||
|
||||
// Commercial terms
|
||||
@Column({ name: 'discount_labor_pct', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountLaborPct: number;
|
||||
|
||||
@Column({ name: 'discount_parts_pct', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
discountPartsPct: number;
|
||||
|
||||
@Column({ name: 'credit_days', type: 'integer', default: 0 })
|
||||
creditDays: number;
|
||||
|
||||
@Column({ name: 'credit_limit', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||
creditLimit: number;
|
||||
|
||||
@Column({ name: 'vehicle_count', type: 'integer', default: 0 })
|
||||
vehicleCount: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@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
|
||||
@OneToMany(() => Vehicle, vehicle => vehicle.fleet)
|
||||
vehicles: Vehicle[];
|
||||
}
|
||||
10
src/modules/vehicle-management/entities/index.ts
Normal file
10
src/modules/vehicle-management/entities/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Vehicle Management Entities Index
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*/
|
||||
|
||||
export * from './vehicle.entity';
|
||||
export * from './fleet.entity';
|
||||
export * from './vehicle-engine.entity';
|
||||
export * from './engine-catalog.entity';
|
||||
export * from './maintenance-reminder.entity';
|
||||
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Maintenance Reminder Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents scheduled maintenance reminders for vehicles.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Vehicle } from './vehicle.entity';
|
||||
|
||||
export enum FrequencyType {
|
||||
TIME = 'time',
|
||||
ODOMETER = 'odometer',
|
||||
BOTH = 'both',
|
||||
}
|
||||
|
||||
export enum ReminderStatus {
|
||||
ACTIVE = 'active',
|
||||
PAUSED = 'paused',
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
|
||||
@Entity({ name: 'maintenance_reminders', schema: 'vehicle_management' })
|
||||
@Index('idx_reminders_tenant', ['tenantId'])
|
||||
@Index('idx_reminders_vehicle', ['vehicleId'])
|
||||
@Index('idx_reminders_due_date', ['nextDueDate'])
|
||||
export class MaintenanceReminder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId: string;
|
||||
|
||||
@Column({ name: 'service_type', type: 'varchar', length: 100 })
|
||||
serviceType: string;
|
||||
|
||||
@Column({ name: 'service_id', type: 'uuid', nullable: true })
|
||||
serviceId?: string;
|
||||
|
||||
@Column({ name: 'frequency_type', type: 'varchar', length: 20 })
|
||||
frequencyType: FrequencyType;
|
||||
|
||||
// Intervals
|
||||
@Column({ name: 'interval_days', type: 'integer', nullable: true })
|
||||
intervalDays?: number;
|
||||
|
||||
@Column({ name: 'interval_km', type: 'integer', nullable: true })
|
||||
intervalKm?: number;
|
||||
|
||||
// Last service
|
||||
@Column({ name: 'last_service_date', type: 'date', nullable: true })
|
||||
lastServiceDate?: Date;
|
||||
|
||||
@Column({ name: 'last_service_km', type: 'integer', nullable: true })
|
||||
lastServiceKm?: number;
|
||||
|
||||
// Next due
|
||||
@Column({ name: 'next_due_date', type: 'date', nullable: true })
|
||||
nextDueDate?: Date;
|
||||
|
||||
@Column({ name: 'next_due_km', type: 'integer', nullable: true })
|
||||
nextDueKm?: number;
|
||||
|
||||
// Notifications
|
||||
@Column({ name: 'notify_days_before', type: 'integer', default: 7 })
|
||||
notifyDaysBefore: number;
|
||||
|
||||
@Column({ name: 'notify_km_before', type: 'integer', default: 1000 })
|
||||
notifyKmBefore: number;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: ReminderStatus.ACTIVE,
|
||||
})
|
||||
status: ReminderStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Vehicle, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle: Vehicle;
|
||||
}
|
||||
107
src/modules/vehicle-management/entities/vehicle-engine.entity.ts
Normal file
107
src/modules/vehicle-management/entities/vehicle-engine.entity.ts
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Vehicle Engine Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents engine specifications for a vehicle.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToOne,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Vehicle } from './vehicle.entity';
|
||||
import { EngineCatalog } from './engine-catalog.entity';
|
||||
|
||||
export enum TurboType {
|
||||
VGT = 'VGT',
|
||||
WASTEGATE = 'wastegate',
|
||||
TWIN = 'twin',
|
||||
COMPOUND = 'compound',
|
||||
}
|
||||
|
||||
@Entity({ name: 'vehicle_engines', schema: 'vehicle_management' })
|
||||
@Index('idx_vehicle_engines_vehicle', ['vehicleId'])
|
||||
@Index('idx_vehicle_engines_serial', ['serialNumber'])
|
||||
@Index('idx_vehicle_engines_catalog', ['engineCatalogId'])
|
||||
export class VehicleEngine {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId: string;
|
||||
|
||||
@Column({ name: 'engine_catalog_id', type: 'uuid', nullable: true })
|
||||
engineCatalogId?: string;
|
||||
|
||||
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
|
||||
serialNumber?: string;
|
||||
|
||||
// Performance specs
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
horsepower?: number;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
torque?: number;
|
||||
|
||||
// ECM
|
||||
@Column({ name: 'ecm_model', type: 'varchar', length: 50, nullable: true })
|
||||
ecmModel?: string;
|
||||
|
||||
@Column({ name: 'ecm_software', type: 'varchar', length: 50, nullable: true })
|
||||
ecmSoftware?: string;
|
||||
|
||||
// Injection system
|
||||
@Column({ name: 'injection_system', type: 'varchar', length: 50, nullable: true })
|
||||
injectionSystem?: string;
|
||||
|
||||
@Column({ name: 'rail_pressure_max', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
railPressureMax?: number;
|
||||
|
||||
@Column({ name: 'injector_count', type: 'integer', nullable: true })
|
||||
injectorCount?: number;
|
||||
|
||||
// Turbo
|
||||
@Column({ name: 'turbo_type', type: 'varchar', length: 50, nullable: true })
|
||||
turboType?: TurboType;
|
||||
|
||||
@Column({ name: 'turbo_make', type: 'varchar', length: 50, nullable: true })
|
||||
turboMake?: string;
|
||||
|
||||
@Column({ name: 'turbo_model', type: 'varchar', length: 50, nullable: true })
|
||||
turboModel?: string;
|
||||
|
||||
// Dates
|
||||
@Column({ name: 'manufacture_date', type: 'date', nullable: true })
|
||||
manufactureDate?: Date;
|
||||
|
||||
@Column({ name: 'rebuild_date', type: 'date', nullable: true })
|
||||
rebuildDate?: Date;
|
||||
|
||||
@Column({ name: 'rebuild_odometer', type: 'integer', nullable: true })
|
||||
rebuildOdometer?: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToOne(() => Vehicle, vehicle => vehicle.engine, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle: Vehicle;
|
||||
|
||||
@ManyToOne(() => EngineCatalog, { nullable: true })
|
||||
@JoinColumn({ name: 'engine_catalog_id' })
|
||||
engineCatalog?: EngineCatalog;
|
||||
}
|
||||
129
src/modules/vehicle-management/entities/vehicle.entity.ts
Normal file
129
src/modules/vehicle-management/entities/vehicle.entity.ts
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Vehicle Entity
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Represents vehicles registered in the workshop.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Fleet } from './fleet.entity';
|
||||
import { VehicleEngine } from './vehicle-engine.entity';
|
||||
|
||||
export enum VehicleType {
|
||||
TRUCK = 'truck',
|
||||
TRAILER = 'trailer',
|
||||
BUS = 'bus',
|
||||
PICKUP = 'pickup',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum VehicleStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
SOLD = 'sold',
|
||||
}
|
||||
|
||||
@Entity({ name: 'vehicles', schema: 'vehicle_management' })
|
||||
@Index('idx_vehicles_tenant', ['tenantId'])
|
||||
@Index('idx_vehicles_customer', ['customerId'])
|
||||
@Index('idx_vehicles_fleet', ['fleetId'])
|
||||
@Index('idx_vehicles_vin', ['vin'])
|
||||
@Index('idx_vehicles_plate', ['licensePlate'])
|
||||
export class Vehicle {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid' })
|
||||
customerId: string;
|
||||
|
||||
@Column({ name: 'fleet_id', type: 'uuid', nullable: true })
|
||||
fleetId?: string;
|
||||
|
||||
// Identification
|
||||
@Column({ type: 'varchar', length: 17, nullable: true })
|
||||
vin?: string;
|
||||
|
||||
@Column({ name: 'license_plate', type: 'varchar', length: 15 })
|
||||
licensePlate: string;
|
||||
|
||||
@Column({ name: 'economic_number', type: 'varchar', length: 20, nullable: true })
|
||||
economicNumber?: string;
|
||||
|
||||
// Vehicle info
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
make: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
model: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
year: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, nullable: true })
|
||||
color?: string;
|
||||
|
||||
@Column({
|
||||
name: 'vehicle_type',
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
default: VehicleType.TRUCK,
|
||||
})
|
||||
vehicleType: VehicleType;
|
||||
|
||||
// Odometer
|
||||
@Column({ name: 'current_odometer', type: 'integer', nullable: true })
|
||||
currentOdometer?: number;
|
||||
|
||||
@Column({ name: 'odometer_updated_at', type: 'timestamptz', nullable: true })
|
||||
odometerUpdatedAt?: Date;
|
||||
|
||||
@Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true })
|
||||
photoUrl?: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: VehicleStatus.ACTIVE,
|
||||
})
|
||||
status: VehicleStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Fleet, fleet => fleet.vehicles, { nullable: true })
|
||||
@JoinColumn({ name: 'fleet_id' })
|
||||
fleet?: Fleet;
|
||||
|
||||
@OneToOne(() => VehicleEngine, engine => engine.vehicle)
|
||||
engine?: VehicleEngine;
|
||||
|
||||
// @OneToMany(() => ServiceOrder, order => order.vehicle)
|
||||
// serviceOrders: ServiceOrder[];
|
||||
|
||||
// @OneToMany(() => MaintenanceReminder, reminder => reminder.vehicle)
|
||||
// reminders: MaintenanceReminder[];
|
||||
|
||||
// @OneToMany(() => VehicleDocument, doc => doc.vehicle)
|
||||
// documents: VehicleDocument[];
|
||||
}
|
||||
19
src/modules/vehicle-management/index.ts
Normal file
19
src/modules/vehicle-management/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Vehicle Management Module
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*/
|
||||
|
||||
// Entities
|
||||
export { Vehicle, VehicleType, VehicleStatus } from './entities/vehicle.entity';
|
||||
export { Fleet } from './entities/fleet.entity';
|
||||
export { VehicleEngine } from './entities/vehicle-engine.entity';
|
||||
export { EngineCatalog } from './entities/engine-catalog.entity';
|
||||
export { MaintenanceReminder } from './entities/maintenance-reminder.entity';
|
||||
|
||||
// Services
|
||||
export { VehicleService, CreateVehicleDto, UpdateVehicleDto, VehicleFilters } from './services/vehicle.service';
|
||||
export { FleetService, CreateFleetDto, UpdateFleetDto } from './services/fleet.service';
|
||||
|
||||
// Controllers
|
||||
export { createVehicleController } from './controllers/vehicle.controller';
|
||||
export { createFleetController } from './controllers/fleet.controller';
|
||||
207
src/modules/vehicle-management/services/fleet.service.ts
Normal file
207
src/modules/vehicle-management/services/fleet.service.ts
Normal file
@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Fleet Service
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for fleet management.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Fleet } from '../entities/fleet.entity';
|
||||
import { Vehicle, VehicleStatus } from '../entities/vehicle.entity';
|
||||
|
||||
// DTOs
|
||||
export interface CreateFleetDto {
|
||||
name: string;
|
||||
code?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
discountLaborPct?: number;
|
||||
discountPartsPct?: number;
|
||||
creditDays?: number;
|
||||
creditLimit?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFleetDto {
|
||||
name?: string;
|
||||
code?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
discountLaborPct?: number;
|
||||
discountPartsPct?: number;
|
||||
creditDays?: number;
|
||||
creditLimit?: number;
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class FleetService {
|
||||
private fleetRepository: Repository<Fleet>;
|
||||
private vehicleRepository: Repository<Vehicle>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.fleetRepository = dataSource.getRepository(Fleet);
|
||||
this.vehicleRepository = dataSource.getRepository(Vehicle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new fleet
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateFleetDto): Promise<Fleet> {
|
||||
const fleet = this.fleetRepository.create({
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
code: dto.code,
|
||||
contactName: dto.contactName,
|
||||
contactPhone: dto.contactPhone,
|
||||
contactEmail: dto.contactEmail,
|
||||
discountLaborPct: dto.discountLaborPct || 0,
|
||||
discountPartsPct: dto.discountPartsPct || 0,
|
||||
creditDays: dto.creditDays || 0,
|
||||
creditLimit: dto.creditLimit || 0,
|
||||
notes: dto.notes,
|
||||
isActive: true,
|
||||
vehicleCount: 0,
|
||||
});
|
||||
|
||||
return this.fleetRepository.save(fleet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find fleet by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<Fleet | null> {
|
||||
return this.fleetRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List fleets
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
pagination = { page: 1, limit: 20 }
|
||||
) {
|
||||
const queryBuilder = this.fleetRepository.createQueryBuilder('fleet')
|
||||
.where('fleet.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
const skip = (pagination.page - 1) * pagination.limit;
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('fleet.name', 'ASC')
|
||||
.skip(skip)
|
||||
.take(pagination.limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
totalPages: Math.ceil(total / pagination.limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fleet
|
||||
*/
|
||||
async update(tenantId: string, id: string, dto: UpdateFleetDto): Promise<Fleet | null> {
|
||||
const fleet = await this.findById(tenantId, id);
|
||||
if (!fleet) return null;
|
||||
|
||||
Object.assign(fleet, dto);
|
||||
return this.fleetRepository.save(fleet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate fleet
|
||||
*/
|
||||
async deactivate(tenantId: string, id: string): Promise<boolean> {
|
||||
const fleet = await this.findById(tenantId, id);
|
||||
if (!fleet) return false;
|
||||
|
||||
fleet.isActive = false;
|
||||
await this.fleetRepository.save(fleet);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fleet with vehicle count
|
||||
*/
|
||||
async getFleetWithStats(tenantId: string, id: string): Promise<{
|
||||
fleet: Fleet;
|
||||
vehicleCount: number;
|
||||
activeVehicles: number;
|
||||
} | null> {
|
||||
const fleet = await this.findById(tenantId, id);
|
||||
if (!fleet) return null;
|
||||
|
||||
const [vehicleCount, activeVehicles] = await Promise.all([
|
||||
this.vehicleRepository.count({ where: { tenantId, fleetId: id } }),
|
||||
this.vehicleRepository.count({ where: { tenantId, fleetId: id, status: VehicleStatus.ACTIVE } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
fleet,
|
||||
vehicleCount,
|
||||
activeVehicles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active fleets
|
||||
*/
|
||||
async findActive(tenantId: string): Promise<Fleet[]> {
|
||||
return this.fleetRepository.find({
|
||||
where: { tenantId, isActive: true },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add vehicle to fleet
|
||||
*/
|
||||
async addVehicle(tenantId: string, fleetId: string, vehicleId: string): Promise<boolean> {
|
||||
const fleet = await this.findById(tenantId, fleetId);
|
||||
if (!fleet) return false;
|
||||
|
||||
const vehicle = await this.vehicleRepository.findOne({
|
||||
where: { id: vehicleId, tenantId },
|
||||
});
|
||||
if (!vehicle) return false;
|
||||
|
||||
vehicle.fleetId = fleetId;
|
||||
await this.vehicleRepository.save(vehicle);
|
||||
|
||||
// Update vehicle count
|
||||
fleet.vehicleCount = await this.vehicleRepository.count({ where: { tenantId, fleetId } });
|
||||
await this.fleetRepository.save(fleet);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove vehicle from fleet
|
||||
*/
|
||||
async removeVehicle(tenantId: string, fleetId: string, vehicleId: string): Promise<boolean> {
|
||||
const vehicle = await this.vehicleRepository.findOne({
|
||||
where: { id: vehicleId, tenantId, fleetId },
|
||||
});
|
||||
if (!vehicle) return false;
|
||||
|
||||
const fleet = await this.findById(tenantId, fleetId);
|
||||
if (!fleet) return false;
|
||||
|
||||
vehicle.fleetId = undefined;
|
||||
await this.vehicleRepository.save(vehicle);
|
||||
|
||||
// Update vehicle count
|
||||
fleet.vehicleCount = await this.vehicleRepository.count({ where: { tenantId, fleetId } });
|
||||
await this.fleetRepository.save(fleet);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
319
src/modules/vehicle-management/services/vehicle.service.ts
Normal file
319
src/modules/vehicle-management/services/vehicle.service.ts
Normal file
@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Vehicle Service
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
*
|
||||
* Business logic for vehicle management.
|
||||
*/
|
||||
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { Vehicle, VehicleType, VehicleStatus } from '../entities/vehicle.entity';
|
||||
|
||||
// DTOs
|
||||
export interface CreateVehicleDto {
|
||||
customerId: string;
|
||||
fleetId?: string;
|
||||
licensePlate: string;
|
||||
vin?: string;
|
||||
make: string;
|
||||
model: string;
|
||||
year: number;
|
||||
color?: string;
|
||||
vehicleType?: VehicleType;
|
||||
economicNumber?: string;
|
||||
currentOdometer?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateVehicleDto {
|
||||
licensePlate?: string;
|
||||
vin?: string;
|
||||
color?: string;
|
||||
vehicleType?: VehicleType;
|
||||
economicNumber?: string;
|
||||
currentOdometer?: number;
|
||||
notes?: string;
|
||||
status?: VehicleStatus;
|
||||
}
|
||||
|
||||
export interface VehicleFilters {
|
||||
customerId?: string;
|
||||
fleetId?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
vehicleType?: VehicleType;
|
||||
search?: string;
|
||||
status?: VehicleStatus;
|
||||
}
|
||||
|
||||
export class VehicleService {
|
||||
private vehicleRepository: Repository<Vehicle>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.vehicleRepository = dataSource.getRepository(Vehicle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new vehicle
|
||||
*/
|
||||
async create(tenantId: string, dto: CreateVehicleDto): Promise<Vehicle> {
|
||||
// Check for duplicate plate number
|
||||
const existing = await this.vehicleRepository.findOne({
|
||||
where: { tenantId, licensePlate: dto.licensePlate },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Vehicle with plate ${dto.licensePlate} already exists`);
|
||||
}
|
||||
|
||||
const vehicle = this.vehicleRepository.create({
|
||||
tenantId,
|
||||
customerId: dto.customerId,
|
||||
fleetId: dto.fleetId,
|
||||
licensePlate: dto.licensePlate,
|
||||
vin: dto.vin,
|
||||
make: dto.make,
|
||||
model: dto.model,
|
||||
year: dto.year,
|
||||
color: dto.color,
|
||||
vehicleType: dto.vehicleType || VehicleType.TRUCK,
|
||||
economicNumber: dto.economicNumber,
|
||||
currentOdometer: dto.currentOdometer,
|
||||
notes: dto.notes,
|
||||
status: VehicleStatus.ACTIVE,
|
||||
});
|
||||
|
||||
return this.vehicleRepository.save(vehicle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find vehicle by ID
|
||||
*/
|
||||
async findById(tenantId: string, id: string): Promise<Vehicle | null> {
|
||||
return this.vehicleRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find vehicle by plate number
|
||||
*/
|
||||
async findByPlate(tenantId: string, licensePlate: string): Promise<Vehicle | null> {
|
||||
return this.vehicleRepository.findOne({
|
||||
where: { tenantId, licensePlate },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find vehicle by VIN
|
||||
*/
|
||||
async findByVin(tenantId: string, vin: string): Promise<Vehicle | null> {
|
||||
return this.vehicleRepository.findOne({
|
||||
where: { tenantId, vin },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List vehicles with filters
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: VehicleFilters = {},
|
||||
pagination = { page: 1, limit: 20 }
|
||||
) {
|
||||
const queryBuilder = this.vehicleRepository.createQueryBuilder('vehicle')
|
||||
.where('vehicle.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (filters.customerId) {
|
||||
queryBuilder.andWhere('vehicle.customer_id = :customerId', { customerId: filters.customerId });
|
||||
}
|
||||
if (filters.fleetId) {
|
||||
queryBuilder.andWhere('vehicle.fleet_id = :fleetId', { fleetId: filters.fleetId });
|
||||
}
|
||||
if (filters.make) {
|
||||
queryBuilder.andWhere('vehicle.make = :make', { make: filters.make });
|
||||
}
|
||||
if (filters.model) {
|
||||
queryBuilder.andWhere('vehicle.model = :model', { model: filters.model });
|
||||
}
|
||||
if (filters.year) {
|
||||
queryBuilder.andWhere('vehicle.year = :year', { year: filters.year });
|
||||
}
|
||||
if (filters.vehicleType) {
|
||||
queryBuilder.andWhere('vehicle.vehicle_type = :vehicleType', { vehicleType: filters.vehicleType });
|
||||
}
|
||||
if (filters.status) {
|
||||
queryBuilder.andWhere('vehicle.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(vehicle.license_plate ILIKE :search OR vehicle.make ILIKE :search OR vehicle.model ILIKE :search OR vehicle.vin ILIKE :search)',
|
||||
{ search: `%${filters.search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (pagination.page - 1) * pagination.limit;
|
||||
|
||||
const [data, total] = await queryBuilder
|
||||
.orderBy('vehicle.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 vehicle
|
||||
*/
|
||||
async update(tenantId: string, id: string, dto: UpdateVehicleDto): Promise<Vehicle | null> {
|
||||
const vehicle = await this.findById(tenantId, id);
|
||||
if (!vehicle) return null;
|
||||
|
||||
// Check plate number uniqueness if changing
|
||||
if (dto.licensePlate && dto.licensePlate !== vehicle.licensePlate) {
|
||||
const existing = await this.findByPlate(tenantId, dto.licensePlate);
|
||||
if (existing) {
|
||||
throw new Error(`Vehicle with plate ${dto.licensePlate} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(vehicle, dto);
|
||||
return this.vehicleRepository.save(vehicle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update odometer
|
||||
*/
|
||||
async updateOdometer(tenantId: string, id: string, odometer: number): Promise<Vehicle | null> {
|
||||
const vehicle = await this.findById(tenantId, id);
|
||||
if (!vehicle) return null;
|
||||
|
||||
if (odometer < (vehicle.currentOdometer || 0)) {
|
||||
throw new Error('New odometer reading cannot be less than current');
|
||||
}
|
||||
|
||||
vehicle.currentOdometer = odometer;
|
||||
vehicle.odometerUpdatedAt = new Date();
|
||||
return this.vehicleRepository.save(vehicle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate vehicle
|
||||
*/
|
||||
async deactivate(tenantId: string, id: string): Promise<boolean> {
|
||||
const vehicle = await this.findById(tenantId, id);
|
||||
if (!vehicle) return false;
|
||||
|
||||
vehicle.status = VehicleStatus.INACTIVE;
|
||||
await this.vehicleRepository.save(vehicle);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's vehicles
|
||||
*/
|
||||
async findByCustomer(tenantId: string, customerId: string): Promise<Vehicle[]> {
|
||||
return this.vehicleRepository.find({
|
||||
where: { tenantId, customerId, status: VehicleStatus.ACTIVE },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fleet's vehicles
|
||||
*/
|
||||
async findByFleet(tenantId: string, fleetId: string): Promise<Vehicle[]> {
|
||||
return this.vehicleRepository.find({
|
||||
where: { tenantId, fleetId, status: VehicleStatus.ACTIVE },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique makes for filters
|
||||
*/
|
||||
async getMakes(tenantId: string): Promise<string[]> {
|
||||
const result = await this.vehicleRepository
|
||||
.createQueryBuilder('vehicle')
|
||||
.select('DISTINCT vehicle.make', 'make')
|
||||
.where('vehicle.tenant_id = :tenantId', { tenantId })
|
||||
.orderBy('vehicle.make', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map(r => r.make);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for a make
|
||||
*/
|
||||
async getModels(tenantId: string, make: string): Promise<string[]> {
|
||||
const result = await this.vehicleRepository
|
||||
.createQueryBuilder('vehicle')
|
||||
.select('DISTINCT vehicle.model', 'model')
|
||||
.where('vehicle.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('vehicle.make = :make', { make })
|
||||
.orderBy('vehicle.model', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map(r => r.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vehicle statistics
|
||||
*/
|
||||
async getStats(tenantId: string): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
byVehicleType: Record<VehicleType, number>;
|
||||
byMake: { make: string; count: number }[];
|
||||
}> {
|
||||
const [total, active, vehicleTypeCounts, makeCounts] = await Promise.all([
|
||||
this.vehicleRepository.count({ where: { tenantId } }),
|
||||
this.vehicleRepository.count({ where: { tenantId, status: VehicleStatus.ACTIVE } }),
|
||||
this.vehicleRepository
|
||||
.createQueryBuilder('vehicle')
|
||||
.select('vehicle.vehicle_type', 'vehicleType')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('vehicle.tenant_id = :tenantId', { tenantId })
|
||||
.groupBy('vehicle.vehicle_type')
|
||||
.getRawMany(),
|
||||
this.vehicleRepository
|
||||
.createQueryBuilder('vehicle')
|
||||
.select('vehicle.make', 'make')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('vehicle.tenant_id = :tenantId', { tenantId })
|
||||
.groupBy('vehicle.make')
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(10)
|
||||
.getRawMany(),
|
||||
]);
|
||||
|
||||
const byVehicleType: Record<VehicleType, number> = {
|
||||
[VehicleType.TRUCK]: 0,
|
||||
[VehicleType.TRAILER]: 0,
|
||||
[VehicleType.BUS]: 0,
|
||||
[VehicleType.PICKUP]: 0,
|
||||
[VehicleType.OTHER]: 0,
|
||||
};
|
||||
|
||||
for (const row of vehicleTypeCounts) {
|
||||
if (row.vehicleType) {
|
||||
byVehicleType[row.vehicleType as VehicleType] = parseInt(row.count, 10);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
active,
|
||||
byVehicleType,
|
||||
byMake: makeCounts.map(r => ({ make: r.make, count: parseInt(r.count, 10) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
32
tsconfig.json
Normal file
32
tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"paths": {
|
||||
"@modules/*": ["./src/modules/*"],
|
||||
"@shared/*": ["./src/shared/*"],
|
||||
"@config/*": ["./src/config/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user