Initial deploy commit

This commit is contained in:
rckrdmrd 2025-12-12 14:39:26 -06:00
commit 10448633cf
43 changed files with 13622 additions and 0 deletions

22
.env.example Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

63
package.json Normal file
View 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
View 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();

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

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

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

View File

@ -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[];
}

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

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

View File

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

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

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

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

View File

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

View 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
View 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"]
}