feat(service-management): Implement Sprint 2 service order workflow
Sprint 2 - S2-T03, S2-T04, S2-T05 New services: - VehicleReceptionService: Vehicle reception workflow - receiveVehicle, createQuickReception - getReceptionForm, validateVehiclePlate - Work bay management - Reception statistics - DiagnosticService (enhanced): Complete diagnostic workflow - createDiagnostic with order status update - addScannerResults with DTC code parsing - completeDiagnostic with quote trigger - generateDiagnosticReport - 70+ diesel DTC codes database - QuoteService (enhanced): Full quotation workflow - createQuote, addQuoteItem, updateQuoteItem - recalculateQuoteTotals (16% IVA) - sendQuoteToCustomer, customerResponse - convertToOrder New entities: - QuoteItem entity for quote line items Fix: rbac.middleware.ts - Use role (singular) instead of roles Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f4ed131c1e
commit
b455de93b2
@ -3,11 +3,21 @@
|
|||||||
* Mecánicas Diesel - ERP Suite
|
* Mecánicas Diesel - ERP Suite
|
||||||
*
|
*
|
||||||
* REST API endpoints for vehicle diagnostics.
|
* REST API endpoints for vehicle diagnostics.
|
||||||
|
* Supports the complete diagnostic workflow including:
|
||||||
|
* - Creating diagnostics for service orders
|
||||||
|
* - Processing scanner results (OBD-II codes)
|
||||||
|
* - Completing diagnostics with quote generation
|
||||||
|
* - Generating diagnostic reports
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { DiagnosticService } from '../services/diagnostic.service';
|
import {
|
||||||
|
DiagnosticService,
|
||||||
|
CreateDiagnosticDto,
|
||||||
|
UpdateDiagnosticDto,
|
||||||
|
ScannerDataDto,
|
||||||
|
} from '../services/diagnostic.service';
|
||||||
import { DiagnosticType, DiagnosticResult } from '../entities/diagnostic.entity';
|
import { DiagnosticType, DiagnosticResult } from '../entities/diagnostic.entity';
|
||||||
|
|
||||||
interface TenantRequest extends Request {
|
interface TenantRequest extends Request {
|
||||||
@ -31,11 +41,63 @@ export function createDiagnosticController(dataSource: DataSource): Router {
|
|||||||
|
|
||||||
router.use(extractTenant);
|
router.use(extractTenant);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CORE DIAGNOSTIC ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new diagnostic
|
* Create a new diagnostic for an order
|
||||||
* POST /api/diagnostics
|
* POST /api/diagnostics
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - orderId: string (required) - Service order ID
|
||||||
|
* - type: DiagnosticType (required) - Type of diagnostic
|
||||||
|
* - findings?: string - Initial findings
|
||||||
|
* - recommendations?: string - Initial recommendations
|
||||||
|
* - estimatedCost?: number - Estimated repair cost
|
||||||
|
* - scannerData?: ScannerDataDto - Scanner data if applicable
|
||||||
|
* - equipment?: string - Equipment used
|
||||||
|
* - performedBy?: string - Technician ID
|
||||||
*/
|
*/
|
||||||
router.post('/', async (req: TenantRequest, res: Response) => {
|
router.post('/', async (req: TenantRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const dto: CreateDiagnosticDto = {
|
||||||
|
orderId: req.body.orderId,
|
||||||
|
type: req.body.type || req.body.diagnosticType,
|
||||||
|
findings: req.body.findings,
|
||||||
|
recommendations: req.body.recommendations,
|
||||||
|
estimatedCost: req.body.estimatedCost,
|
||||||
|
scannerData: req.body.scannerData,
|
||||||
|
equipment: req.body.equipment,
|
||||||
|
performedBy: req.body.performedBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!dto.orderId) {
|
||||||
|
return res.status(400).json({ error: 'orderId is required' });
|
||||||
|
}
|
||||||
|
if (!dto.type) {
|
||||||
|
return res.status(400).json({ error: 'type is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnostic = await service.createDiagnostic(req.tenantId!, dto, req.userId);
|
||||||
|
res.status(201).json(diagnostic);
|
||||||
|
} catch (error) {
|
||||||
|
const message = (error as Error).message;
|
||||||
|
if (message.includes('not found')) {
|
||||||
|
return res.status(404).json({ error: message });
|
||||||
|
}
|
||||||
|
if (message.includes('Cannot create diagnostic')) {
|
||||||
|
return res.status(409).json({ error: message });
|
||||||
|
}
|
||||||
|
res.status(400).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a basic diagnostic (legacy endpoint)
|
||||||
|
* POST /api/diagnostics/basic
|
||||||
|
*/
|
||||||
|
router.post('/basic', async (req: TenantRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const diagnostic = await service.create(req.tenantId!, req.body);
|
const diagnostic = await service.create(req.tenantId!, req.body);
|
||||||
res.status(201).json(diagnostic);
|
res.status(201).json(diagnostic);
|
||||||
@ -45,7 +107,7 @@ export function createDiagnosticController(dataSource: DataSource): Router {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single diagnostic
|
* Get a single diagnostic by ID
|
||||||
* GET /api/diagnostics/:id
|
* GET /api/diagnostics/:id
|
||||||
*/
|
*/
|
||||||
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
router.get('/:id', async (req: TenantRequest, res: Response) => {
|
||||||
@ -60,13 +122,58 @@ export function createDiagnosticController(dataSource: DataSource): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a diagnostic
|
||||||
|
* PATCH /api/diagnostics/:id
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - findings?: string
|
||||||
|
* - recommendations?: string
|
||||||
|
* - result?: DiagnosticResult
|
||||||
|
* - scannerData?: ScannerDataDto
|
||||||
|
* - equipment?: string
|
||||||
|
* - estimatedCost?: number
|
||||||
|
*/
|
||||||
|
router.patch('/:id', async (req: TenantRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const dto: UpdateDiagnosticDto = {
|
||||||
|
findings: req.body.findings,
|
||||||
|
recommendations: req.body.recommendations,
|
||||||
|
result: req.body.result,
|
||||||
|
scannerData: req.body.scannerData,
|
||||||
|
equipment: req.body.equipment,
|
||||||
|
estimatedCost: req.body.estimatedCost,
|
||||||
|
};
|
||||||
|
|
||||||
|
const diagnostic = await service.updateDiagnostic(req.tenantId!, req.params.id, dto);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DIAGNOSTIC QUERY ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get diagnostics by vehicle
|
* Get diagnostics by vehicle
|
||||||
* GET /api/diagnostics/vehicle/:vehicleId
|
* GET /api/diagnostics/vehicle/:vehicleId
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - limit?: number (default: 50)
|
||||||
*/
|
*/
|
||||||
router.get('/vehicle/:vehicleId', async (req: TenantRequest, res: Response) => {
|
router.get('/vehicle/:vehicleId', async (req: TenantRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const diagnostics = await service.findByVehicle(req.tenantId!, req.params.vehicleId);
|
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 50;
|
||||||
|
const diagnostics = await service.getDiagnosticsByVehicle(
|
||||||
|
req.tenantId!,
|
||||||
|
req.params.vehicleId,
|
||||||
|
limit
|
||||||
|
);
|
||||||
res.json(diagnostics);
|
res.json(diagnostics);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
res.status(500).json({ error: (error as Error).message });
|
||||||
@ -79,7 +186,7 @@ export function createDiagnosticController(dataSource: DataSource): Router {
|
|||||||
*/
|
*/
|
||||||
router.get('/order/:orderId', async (req: TenantRequest, res: Response) => {
|
router.get('/order/:orderId', async (req: TenantRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const diagnostics = await service.findByOrder(req.tenantId!, req.params.orderId);
|
const diagnostics = await service.getDiagnosticsByOrder(req.tenantId!, req.params.orderId);
|
||||||
res.json(diagnostics);
|
res.json(diagnostics);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
res.status(500).json({ error: (error as Error).message });
|
||||||
@ -99,13 +206,118 @@ export function createDiagnosticController(dataSource: DataSource): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCANNER AND TEST ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update diagnostic result
|
* Add scanner results to a diagnostic
|
||||||
|
* POST /api/diagnostics/:id/scanner-results
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - rawData: string | object (required) - Raw scanner output
|
||||||
|
* - deviceModel?: string
|
||||||
|
* - deviceSerial?: string
|
||||||
|
* - softwareVersion?: string
|
||||||
|
* - timestamp?: Date
|
||||||
|
*/
|
||||||
|
router.post('/:id/scanner-results', async (req: TenantRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const scannerData: ScannerDataDto = {
|
||||||
|
rawData: req.body.rawData,
|
||||||
|
deviceModel: req.body.deviceModel,
|
||||||
|
deviceSerial: req.body.deviceSerial,
|
||||||
|
softwareVersion: req.body.softwareVersion,
|
||||||
|
timestamp: req.body.timestamp ? new Date(req.body.timestamp) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!scannerData.rawData) {
|
||||||
|
return res.status(400).json({ error: 'rawData is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnostic = await service.addScannerResults(
|
||||||
|
req.tenantId!,
|
||||||
|
req.params.id,
|
||||||
|
scannerData
|
||||||
|
);
|
||||||
|
res.json(diagnostic);
|
||||||
|
} catch (error) {
|
||||||
|
const message = (error as Error).message;
|
||||||
|
if (message.includes('not found')) {
|
||||||
|
return res.status(404).json({ error: message });
|
||||||
|
}
|
||||||
|
res.status(400).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse DTC codes from raw data (utility endpoint)
|
||||||
|
* POST /api/diagnostics/parse-dtc
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - rawData: string | object - Scanner output to parse
|
||||||
|
*/
|
||||||
|
router.post('/parse-dtc', async (req: TenantRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const items = service.parseDTCCodes(req.body.rawData || req.body || {});
|
||||||
|
res.json(items);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze injector test results (utility endpoint)
|
||||||
|
* POST /api/diagnostics/analyze-injectors
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - rawData: object with injectors/cylinders array
|
||||||
|
*/
|
||||||
|
router.post('/analyze-injectors', async (req: TenantRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const items = service.analyzeInjectorTest(req.body.rawData || req.body || {});
|
||||||
|
res.json(items);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze compression test results (utility endpoint)
|
||||||
|
* POST /api/diagnostics/analyze-compression
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - rawData: object with readings/cylinders/compression array
|
||||||
|
*/
|
||||||
|
router.post('/analyze-compression', async (req: TenantRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const items = service.analyzeCompressionTest(req.body.rawData || req.body || {});
|
||||||
|
res.json(items);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DIAGNOSTIC WORKFLOW ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update diagnostic result (legacy endpoint)
|
||||||
* PATCH /api/diagnostics/:id/result
|
* PATCH /api/diagnostics/:id/result
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - result: DiagnosticResult (required)
|
||||||
|
* - summary?: string
|
||||||
*/
|
*/
|
||||||
router.patch('/:id/result', async (req: TenantRequest, res: Response) => {
|
router.patch('/:id/result', async (req: TenantRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { result, summary } = req.body;
|
const { result, summary } = req.body;
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(400).json({ error: 'result is required' });
|
||||||
|
}
|
||||||
|
|
||||||
const diagnostic = await service.updateResult(
|
const diagnostic = await service.updateResult(
|
||||||
req.tenantId!,
|
req.tenantId!,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
@ -122,28 +334,79 @@ export function createDiagnosticController(dataSource: DataSource): Router {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse DTC codes from raw data
|
* Complete a diagnostic
|
||||||
* POST /api/diagnostics/parse-dtc
|
* POST /api/diagnostics/:id/complete
|
||||||
|
*
|
||||||
|
* Marks diagnostic as complete and optionally triggers quote generation
|
||||||
|
* for FAIL or NEEDS_ATTENTION results.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - result: DiagnosticResult (required) - PASS, FAIL, or NEEDS_ATTENTION
|
||||||
|
* - summary?: string - Final summary
|
||||||
|
* - triggerQuote?: boolean (default: true) - Create draft quote if needed
|
||||||
*/
|
*/
|
||||||
router.post('/parse-dtc', async (req: TenantRequest, res: Response) => {
|
router.post('/:id/complete', async (req: TenantRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const items = service.parseDTCCodes(req.body.rawData || {});
|
const { result, summary, triggerQuote } = req.body;
|
||||||
res.json(items);
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(400).json({ error: 'result is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validResults = Object.values(DiagnosticResult);
|
||||||
|
if (!validResults.includes(result)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Invalid result. Must be one of: ${validResults.join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await service.completeDiagnostic(
|
||||||
|
req.tenantId!,
|
||||||
|
req.params.id,
|
||||||
|
result as DiagnosticResult,
|
||||||
|
summary,
|
||||||
|
triggerQuote !== false
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
diagnostic: response.diagnostic,
|
||||||
|
quote: response.quote,
|
||||||
|
message: response.quote
|
||||||
|
? `Diagnostic completed. Draft quote ${response.quote.quoteNumber} created.`
|
||||||
|
: 'Diagnostic completed.',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ error: (error as Error).message });
|
const message = (error as Error).message;
|
||||||
|
if (message.includes('not found')) {
|
||||||
|
return res.status(404).json({ error: message });
|
||||||
|
}
|
||||||
|
if (message.includes('already been completed')) {
|
||||||
|
return res.status(409).json({ error: message });
|
||||||
|
}
|
||||||
|
res.status(400).json({ error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// REPORT ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analyze injector test results
|
* Generate diagnostic report data
|
||||||
* POST /api/diagnostics/analyze-injectors
|
* GET /api/diagnostics/:id/report
|
||||||
|
*
|
||||||
|
* Returns structured data for generating a printable report.
|
||||||
*/
|
*/
|
||||||
router.post('/analyze-injectors', async (req: TenantRequest, res: Response) => {
|
router.get('/:id/report', async (req: TenantRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const items = service.analyzeInjectorTest(req.body.rawData || {});
|
const reportData = await service.generateDiagnosticReport(req.tenantId!, req.params.id);
|
||||||
res.json(items);
|
res.json(reportData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ error: (error as Error).message });
|
const message = (error as Error).message;
|
||||||
|
if (message.includes('not found')) {
|
||||||
|
return res.status(404).json({ error: message });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Service Management Entities Index
|
* Service Management Entities Index
|
||||||
* Mecánicas Diesel - ERP Suite
|
* Mecanicas Diesel - ERP Suite
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './service-order.entity';
|
export * from './service-order.entity';
|
||||||
export * from './order-item.entity';
|
export * from './order-item.entity';
|
||||||
export * from './diagnostic.entity';
|
export * from './diagnostic.entity';
|
||||||
export * from './quote.entity';
|
export * from './quote.entity';
|
||||||
|
export * from './quote-item.entity';
|
||||||
export * from './work-bay.entity';
|
export * from './work-bay.entity';
|
||||||
export * from './service.entity';
|
export * from './service.entity';
|
||||||
|
|||||||
69
src/modules/service-management/entities/quote-item.entity.ts
Normal file
69
src/modules/service-management/entities/quote-item.entity.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Quote Item Entity
|
||||||
|
* Mecanicas Diesel - ERP Suite
|
||||||
|
*
|
||||||
|
* Represents line items (services or parts) in a quotation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Quote } from './quote.entity';
|
||||||
|
|
||||||
|
export enum QuoteItemType {
|
||||||
|
SERVICE = 'service',
|
||||||
|
PART = 'part',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: 'quote_items', schema: 'service_management' })
|
||||||
|
@Index('idx_quote_items_quote', ['quoteId'])
|
||||||
|
export class QuoteItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'quote_id', type: 'uuid' })
|
||||||
|
quoteId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'item_type', type: 'varchar', length: 20 })
|
||||||
|
itemType: QuoteItemType;
|
||||||
|
|
||||||
|
@Column({ name: 'service_id', type: 'uuid', nullable: true })
|
||||||
|
serviceId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'part_id', type: 'uuid', nullable: true })
|
||||||
|
partId?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500 })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@Column({ name: 'is_approved', type: 'boolean', default: true })
|
||||||
|
isApproved: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'sort_order', type: 'integer', default: 0 })
|
||||||
|
sortOrder: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Quote, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'quote_id' })
|
||||||
|
quote: Quote;
|
||||||
|
}
|
||||||
@ -8,13 +8,45 @@ export { ServiceOrder, ServiceOrderStatus, ServiceOrderPriority } from './entiti
|
|||||||
export { OrderItem, OrderItemType, OrderItemStatus } from './entities/order-item.entity';
|
export { OrderItem, OrderItemType, OrderItemStatus } from './entities/order-item.entity';
|
||||||
export { Diagnostic, DiagnosticType, DiagnosticResult } from './entities/diagnostic.entity';
|
export { Diagnostic, DiagnosticType, DiagnosticResult } from './entities/diagnostic.entity';
|
||||||
export { Quote, QuoteStatus } from './entities/quote.entity';
|
export { Quote, QuoteStatus } from './entities/quote.entity';
|
||||||
|
export { QuoteItem, QuoteItemType } from './entities/quote-item.entity';
|
||||||
export { WorkBay, BayStatus, BayType } from './entities/work-bay.entity';
|
export { WorkBay, BayStatus, BayType } from './entities/work-bay.entity';
|
||||||
export { Service } from './entities/service.entity';
|
export { Service } from './entities/service.entity';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
export { ServiceOrderService, CreateServiceOrderDto, UpdateServiceOrderDto, ServiceOrderFilters } from './services/service-order.service';
|
export { ServiceOrderService, CreateServiceOrderDto, UpdateServiceOrderDto, ServiceOrderFilters } from './services/service-order.service';
|
||||||
export { DiagnosticService, CreateDiagnosticDto, DiagnosticItemDto, DiagnosticRecommendationDto } from './services/diagnostic.service';
|
export {
|
||||||
export { QuoteService, CreateQuoteDto, QuoteItemDto, ApplyDiscountDto } from './services/quote.service';
|
DiagnosticService,
|
||||||
|
CreateDiagnosticDto,
|
||||||
|
UpdateDiagnosticDto,
|
||||||
|
ScannerDataDto,
|
||||||
|
ScannerResultDto,
|
||||||
|
ParsedDTCCode,
|
||||||
|
DiagnosticItemDto,
|
||||||
|
DiagnosticRecommendationDto,
|
||||||
|
DiagnosticReportData,
|
||||||
|
} from './services/diagnostic.service';
|
||||||
|
export {
|
||||||
|
QuoteService,
|
||||||
|
CreateQuoteDto,
|
||||||
|
AddQuoteItemDto,
|
||||||
|
UpdateQuoteItemDto,
|
||||||
|
CustomerResponseDto,
|
||||||
|
ApplyDiscountDto,
|
||||||
|
QuoteFilters,
|
||||||
|
QuoteWithItems,
|
||||||
|
SendQuoteResult,
|
||||||
|
} from './services/quote.service';
|
||||||
|
export {
|
||||||
|
VehicleReceptionService,
|
||||||
|
CreateVehicleReceptionDto,
|
||||||
|
QuickReceptionDto,
|
||||||
|
ReceptionFormData,
|
||||||
|
ServiceOrderSummary,
|
||||||
|
VehicleReceptionResult,
|
||||||
|
PlateValidationResult,
|
||||||
|
NewCustomerInfo,
|
||||||
|
NewVehicleInfo,
|
||||||
|
} from './services/vehicle-reception.service';
|
||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
export { createServiceOrderController } from './controllers/service-order.controller';
|
export { createServiceOrderController } from './controllers/service-order.controller';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,736 @@
|
|||||||
|
/**
|
||||||
|
* Vehicle Reception Service
|
||||||
|
* Mecanicas Diesel - ERP Suite
|
||||||
|
*
|
||||||
|
* Handles the complete vehicle reception workflow including:
|
||||||
|
* - Receiving vehicles for service
|
||||||
|
* - Quick reception for walk-in customers
|
||||||
|
* - Pre-filling reception forms
|
||||||
|
* - Work bay management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
ServiceOrder,
|
||||||
|
ServiceOrderStatus,
|
||||||
|
ServiceOrderPriority,
|
||||||
|
} from '../entities/service-order.entity';
|
||||||
|
import { WorkBay, BayStatus } from '../entities/work-bay.entity';
|
||||||
|
import { Vehicle, VehicleType, VehicleStatus } from '../../vehicle-management/entities/vehicle.entity';
|
||||||
|
import { Customer, CustomerType } from '../../customers/entities/customer.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a vehicle reception
|
||||||
|
*/
|
||||||
|
export interface CreateVehicleReceptionDto {
|
||||||
|
customerId?: string;
|
||||||
|
vehicleId?: string;
|
||||||
|
customerSymptoms: string;
|
||||||
|
odometerIn: number;
|
||||||
|
priority?: ServiceOrderPriority;
|
||||||
|
bayId?: string;
|
||||||
|
internalNotes?: string;
|
||||||
|
promisedAt?: Date;
|
||||||
|
newCustomer?: NewCustomerInfo;
|
||||||
|
newVehicle?: NewVehicleInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New customer info for quick reception
|
||||||
|
*/
|
||||||
|
export interface NewCustomerInfo {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
customerType?: CustomerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New vehicle info for quick reception
|
||||||
|
*/
|
||||||
|
export interface NewVehicleInfo {
|
||||||
|
licensePlate: string;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
year: number;
|
||||||
|
vehicleType?: VehicleType;
|
||||||
|
vin?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for quick reception (walk-in customers)
|
||||||
|
*/
|
||||||
|
export interface QuickReceptionDto {
|
||||||
|
customerName: string;
|
||||||
|
customerPhone: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
vehiclePlate: string;
|
||||||
|
vehicleMake: string;
|
||||||
|
vehicleModel: string;
|
||||||
|
vehicleYear: number;
|
||||||
|
vehicleType?: VehicleType;
|
||||||
|
symptoms: string;
|
||||||
|
odometer: number;
|
||||||
|
priority?: ServiceOrderPriority;
|
||||||
|
bayId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reception form data returned by getReceptionForm
|
||||||
|
*/
|
||||||
|
export interface ReceptionFormData {
|
||||||
|
vehicle: Vehicle | null;
|
||||||
|
customer: Customer | null;
|
||||||
|
availableBays: WorkBay[];
|
||||||
|
serviceHistory: ServiceOrderSummary[];
|
||||||
|
lastOdometer: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of a service order for history display
|
||||||
|
*/
|
||||||
|
export interface ServiceOrderSummary {
|
||||||
|
id: string;
|
||||||
|
orderNumber: string;
|
||||||
|
status: ServiceOrderStatus;
|
||||||
|
receivedAt: Date;
|
||||||
|
completedAt: Date | null;
|
||||||
|
grandTotal: number;
|
||||||
|
customerSymptoms: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of vehicle reception
|
||||||
|
*/
|
||||||
|
export interface VehicleReceptionResult {
|
||||||
|
order: ServiceOrder;
|
||||||
|
vehicle: Vehicle;
|
||||||
|
customer: Customer;
|
||||||
|
bay: WorkBay | null;
|
||||||
|
orderNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plate validation result
|
||||||
|
*/
|
||||||
|
export interface PlateValidationResult {
|
||||||
|
exists: boolean;
|
||||||
|
vehicle: Vehicle | null;
|
||||||
|
customer: Customer | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VehicleReceptionService {
|
||||||
|
private orderRepository: Repository<ServiceOrder>;
|
||||||
|
private vehicleRepository: Repository<Vehicle>;
|
||||||
|
private customerRepository: Repository<Customer>;
|
||||||
|
private bayRepository: Repository<WorkBay>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.orderRepository = dataSource.getRepository(ServiceOrder);
|
||||||
|
this.vehicleRepository = dataSource.getRepository(Vehicle);
|
||||||
|
this.customerRepository = dataSource.getRepository(Customer);
|
||||||
|
this.bayRepository = dataSource.getRepository(WorkBay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate next order number for tenant
|
||||||
|
* Format: OS-YYYY-NNNNN
|
||||||
|
*/
|
||||||
|
private async generateOrderNumber(tenantId: string): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `OS-${year}-`;
|
||||||
|
|
||||||
|
const lastOrder = await this.orderRepository
|
||||||
|
.createQueryBuilder('order')
|
||||||
|
.where('order.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('order.order_number LIKE :prefix', { prefix: `${prefix}%` })
|
||||||
|
.orderBy('order.order_number', 'DESC')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main method to receive a vehicle for service
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID for multi-tenancy
|
||||||
|
* @param dto - Reception data
|
||||||
|
* @param userId - User performing the reception
|
||||||
|
* @returns Created service order with vehicle and customer info
|
||||||
|
*/
|
||||||
|
async receiveVehicle(
|
||||||
|
tenantId: string,
|
||||||
|
dto: CreateVehicleReceptionDto,
|
||||||
|
userId?: string
|
||||||
|
): Promise<VehicleReceptionResult> {
|
||||||
|
return this.dataSource.transaction(async (transactionalEntityManager) => {
|
||||||
|
const orderRepo = transactionalEntityManager.getRepository(ServiceOrder);
|
||||||
|
const vehicleRepo = transactionalEntityManager.getRepository(Vehicle);
|
||||||
|
const customerRepo = transactionalEntityManager.getRepository(Customer);
|
||||||
|
const bayRepo = transactionalEntityManager.getRepository(WorkBay);
|
||||||
|
|
||||||
|
let vehicle: Vehicle;
|
||||||
|
let customer: Customer;
|
||||||
|
|
||||||
|
// Step 1: Resolve or create customer
|
||||||
|
if (dto.customerId) {
|
||||||
|
const existingCustomer = await customerRepo.findOne({
|
||||||
|
where: { id: dto.customerId, tenantId },
|
||||||
|
});
|
||||||
|
if (!existingCustomer) {
|
||||||
|
throw new Error(`Customer with ID ${dto.customerId} not found`);
|
||||||
|
}
|
||||||
|
customer = existingCustomer;
|
||||||
|
} else if (dto.newCustomer) {
|
||||||
|
const newCustomer = customerRepo.create({
|
||||||
|
tenantId,
|
||||||
|
name: dto.newCustomer.name,
|
||||||
|
phone: dto.newCustomer.phone,
|
||||||
|
email: dto.newCustomer.email,
|
||||||
|
customerType: dto.newCustomer.customerType || CustomerType.INDIVIDUAL,
|
||||||
|
isActive: true,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
customer = await customerRepo.save(newCustomer);
|
||||||
|
} else {
|
||||||
|
throw new Error('Either customerId or newCustomer must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Resolve or create vehicle
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
const existingVehicle = await vehicleRepo.findOne({
|
||||||
|
where: { id: dto.vehicleId, tenantId },
|
||||||
|
});
|
||||||
|
if (!existingVehicle) {
|
||||||
|
throw new Error(`Vehicle with ID ${dto.vehicleId} not found`);
|
||||||
|
}
|
||||||
|
vehicle = existingVehicle;
|
||||||
|
|
||||||
|
// Verify vehicle belongs to customer
|
||||||
|
if (vehicle.customerId !== customer.id) {
|
||||||
|
throw new Error('Vehicle does not belong to the specified customer');
|
||||||
|
}
|
||||||
|
} else if (dto.newVehicle) {
|
||||||
|
// Check if plate already exists
|
||||||
|
const existingByPlate = await vehicleRepo.findOne({
|
||||||
|
where: { tenantId, licensePlate: dto.newVehicle.licensePlate },
|
||||||
|
});
|
||||||
|
if (existingByPlate) {
|
||||||
|
throw new Error(`Vehicle with plate ${dto.newVehicle.licensePlate} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newVehicle = vehicleRepo.create({
|
||||||
|
tenantId,
|
||||||
|
customerId: customer.id,
|
||||||
|
licensePlate: dto.newVehicle.licensePlate,
|
||||||
|
make: dto.newVehicle.make,
|
||||||
|
model: dto.newVehicle.model,
|
||||||
|
year: dto.newVehicle.year,
|
||||||
|
vehicleType: dto.newVehicle.vehicleType || VehicleType.TRUCK,
|
||||||
|
vin: dto.newVehicle.vin,
|
||||||
|
color: dto.newVehicle.color,
|
||||||
|
currentOdometer: dto.odometerIn,
|
||||||
|
odometerUpdatedAt: new Date(),
|
||||||
|
status: VehicleStatus.ACTIVE,
|
||||||
|
});
|
||||||
|
vehicle = await vehicleRepo.save(newVehicle);
|
||||||
|
} else {
|
||||||
|
throw new Error('Either vehicleId or newVehicle must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Validate and update odometer
|
||||||
|
if (dto.odometerIn < (vehicle.currentOdometer || 0)) {
|
||||||
|
throw new Error(
|
||||||
|
`Odometer reading (${dto.odometerIn}) cannot be less than current odometer (${vehicle.currentOdometer || 0})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update vehicle odometer
|
||||||
|
vehicle.currentOdometer = dto.odometerIn;
|
||||||
|
vehicle.odometerUpdatedAt = new Date();
|
||||||
|
await vehicleRepo.save(vehicle);
|
||||||
|
|
||||||
|
// Step 4: Assign work bay (optional)
|
||||||
|
let bay: WorkBay | null = null;
|
||||||
|
if (dto.bayId) {
|
||||||
|
bay = await bayRepo.findOne({
|
||||||
|
where: { id: dto.bayId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bay) {
|
||||||
|
throw new Error(`Work bay with ID ${dto.bayId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bay.status !== BayStatus.AVAILABLE) {
|
||||||
|
throw new Error(`Work bay ${bay.name} is not available (current status: ${bay.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Generate order number
|
||||||
|
const orderNumber = await this.generateOrderNumber(tenantId);
|
||||||
|
|
||||||
|
// Step 6: Create service order
|
||||||
|
const order = orderRepo.create({
|
||||||
|
tenantId,
|
||||||
|
orderNumber,
|
||||||
|
customerId: customer.id,
|
||||||
|
vehicleId: vehicle.id,
|
||||||
|
customerSymptoms: dto.customerSymptoms,
|
||||||
|
odometerIn: dto.odometerIn,
|
||||||
|
priority: dto.priority || ServiceOrderPriority.NORMAL,
|
||||||
|
status: ServiceOrderStatus.RECEIVED,
|
||||||
|
bayId: dto.bayId,
|
||||||
|
internalNotes: dto.internalNotes,
|
||||||
|
promisedAt: dto.promisedAt,
|
||||||
|
receivedAt: new Date(),
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedOrder = await orderRepo.save(order);
|
||||||
|
|
||||||
|
// Step 7: Update bay status if assigned
|
||||||
|
if (bay) {
|
||||||
|
bay.status = BayStatus.OCCUPIED;
|
||||||
|
bay.currentOrderId = savedOrder.id;
|
||||||
|
await bayRepo.save(bay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Update customer last visit
|
||||||
|
customer.lastVisitAt = new Date();
|
||||||
|
await customerRepo.save(customer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
order: savedOrder,
|
||||||
|
vehicle,
|
||||||
|
customer,
|
||||||
|
bay,
|
||||||
|
orderNumber,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick reception for walk-in customers
|
||||||
|
* Creates customer and vehicle if they don't exist, then receives the vehicle
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param dto - Quick reception data
|
||||||
|
* @param userId - User performing the reception
|
||||||
|
*/
|
||||||
|
async createQuickReception(
|
||||||
|
tenantId: string,
|
||||||
|
dto: QuickReceptionDto,
|
||||||
|
userId?: string
|
||||||
|
): Promise<VehicleReceptionResult> {
|
||||||
|
// Check if vehicle plate exists
|
||||||
|
const existingVehicle = await this.vehicleRepository.findOne({
|
||||||
|
where: { tenantId, licensePlate: dto.vehiclePlate },
|
||||||
|
});
|
||||||
|
|
||||||
|
let customerId: string | undefined;
|
||||||
|
let vehicleId: string | undefined;
|
||||||
|
let newCustomer: NewCustomerInfo | undefined;
|
||||||
|
let newVehicle: NewVehicleInfo | undefined;
|
||||||
|
|
||||||
|
if (existingVehicle) {
|
||||||
|
// Vehicle exists, use existing customer
|
||||||
|
vehicleId = existingVehicle.id;
|
||||||
|
customerId = existingVehicle.customerId;
|
||||||
|
} else {
|
||||||
|
// Check if customer exists by phone
|
||||||
|
const existingCustomer = await this.customerRepository.findOne({
|
||||||
|
where: { tenantId, phone: dto.customerPhone },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCustomer) {
|
||||||
|
customerId = existingCustomer.id;
|
||||||
|
} else {
|
||||||
|
// Create new customer
|
||||||
|
newCustomer = {
|
||||||
|
name: dto.customerName,
|
||||||
|
phone: dto.customerPhone,
|
||||||
|
email: dto.customerEmail,
|
||||||
|
customerType: CustomerType.INDIVIDUAL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new vehicle
|
||||||
|
newVehicle = {
|
||||||
|
licensePlate: dto.vehiclePlate,
|
||||||
|
make: dto.vehicleMake,
|
||||||
|
model: dto.vehicleModel,
|
||||||
|
year: dto.vehicleYear,
|
||||||
|
vehicleType: dto.vehicleType || VehicleType.TRUCK,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const receptionDto: CreateVehicleReceptionDto = {
|
||||||
|
customerId,
|
||||||
|
vehicleId,
|
||||||
|
newCustomer,
|
||||||
|
newVehicle,
|
||||||
|
customerSymptoms: dto.symptoms,
|
||||||
|
odometerIn: dto.odometer,
|
||||||
|
priority: dto.priority,
|
||||||
|
bayId: dto.bayId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.receiveVehicle(tenantId, receptionDto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pre-filled data for reception form
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param vehicleId - Optional vehicle ID to pre-fill data
|
||||||
|
*/
|
||||||
|
async getReceptionForm(tenantId: string, vehicleId?: string): Promise<ReceptionFormData> {
|
||||||
|
let vehicle: Vehicle | null = null;
|
||||||
|
let customer: Customer | null = null;
|
||||||
|
let lastOdometer: number | null = null;
|
||||||
|
let serviceHistory: ServiceOrderSummary[] = [];
|
||||||
|
|
||||||
|
if (vehicleId) {
|
||||||
|
vehicle = await this.vehicleRepository.findOne({
|
||||||
|
where: { id: vehicleId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vehicle) {
|
||||||
|
customer = await this.customerRepository.findOne({
|
||||||
|
where: { id: vehicle.customerId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
lastOdometer = vehicle.currentOdometer || null;
|
||||||
|
|
||||||
|
// Get recent service history (last 10 orders)
|
||||||
|
const orders = await this.orderRepository.find({
|
||||||
|
where: { tenantId, vehicleId },
|
||||||
|
order: { receivedAt: 'DESC' },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
serviceHistory = orders.map((order) => ({
|
||||||
|
id: order.id,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
status: order.status,
|
||||||
|
receivedAt: order.receivedAt,
|
||||||
|
completedAt: order.completedAt || null,
|
||||||
|
grandTotal: Number(order.grandTotal),
|
||||||
|
customerSymptoms: order.customerSymptoms || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available work bays
|
||||||
|
const availableBays = await this.getAvailableWorkBays(tenantId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
vehicle,
|
||||||
|
customer,
|
||||||
|
availableBays,
|
||||||
|
serviceHistory,
|
||||||
|
lastOdometer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a vehicle plate already exists
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param plate - License plate to validate
|
||||||
|
*/
|
||||||
|
async validateVehiclePlate(tenantId: string, plate: string): Promise<PlateValidationResult> {
|
||||||
|
const vehicle = await this.vehicleRepository.findOne({
|
||||||
|
where: { tenantId, licensePlate: plate.toUpperCase().trim() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vehicle) {
|
||||||
|
return {
|
||||||
|
exists: false,
|
||||||
|
vehicle: null,
|
||||||
|
customer: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await this.customerRepository.findOne({
|
||||||
|
where: { id: vehicle.customerId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
vehicle,
|
||||||
|
customer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available work bays
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
*/
|
||||||
|
async getAvailableWorkBays(tenantId: string): Promise<WorkBay[]> {
|
||||||
|
return this.bayRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
status: BayStatus.AVAILABLE,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all work bays with their current status
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
*/
|
||||||
|
async getAllWorkBays(tenantId: string): Promise<WorkBay[]> {
|
||||||
|
return this.bayRepository.find({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a vehicle/order to a work bay
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param orderId - Service order ID
|
||||||
|
* @param bayId - Work bay ID
|
||||||
|
*/
|
||||||
|
async assignToBay(tenantId: string, orderId: string, bayId: string): Promise<WorkBay> {
|
||||||
|
return this.dataSource.transaction(async (transactionalEntityManager) => {
|
||||||
|
const orderRepo = transactionalEntityManager.getRepository(ServiceOrder);
|
||||||
|
const bayRepo = transactionalEntityManager.getRepository(WorkBay);
|
||||||
|
|
||||||
|
const order = await orderRepo.findOne({
|
||||||
|
where: { id: orderId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new Error(`Service order with ID ${orderId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bay = await bayRepo.findOne({
|
||||||
|
where: { id: bayId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bay) {
|
||||||
|
throw new Error(`Work bay with ID ${bayId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bay.status !== BayStatus.AVAILABLE) {
|
||||||
|
throw new Error(`Work bay ${bay.name} is not available (current status: ${bay.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If order was already in another bay, release that bay
|
||||||
|
if (order.bayId) {
|
||||||
|
const previousBay = await bayRepo.findOne({
|
||||||
|
where: { id: order.bayId, tenantId },
|
||||||
|
});
|
||||||
|
if (previousBay && previousBay.currentOrderId === order.id) {
|
||||||
|
previousBay.status = BayStatus.AVAILABLE;
|
||||||
|
previousBay.currentOrderId = undefined;
|
||||||
|
await bayRepo.save(previousBay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign to new bay
|
||||||
|
bay.status = BayStatus.OCCUPIED;
|
||||||
|
bay.currentOrderId = order.id;
|
||||||
|
await bayRepo.save(bay);
|
||||||
|
|
||||||
|
// Update order
|
||||||
|
order.bayId = bay.id;
|
||||||
|
await orderRepo.save(order);
|
||||||
|
|
||||||
|
return bay;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a work bay
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param bayId - Work bay ID
|
||||||
|
*/
|
||||||
|
async releaseBay(tenantId: string, bayId: string): Promise<WorkBay> {
|
||||||
|
const bay = await this.bayRepository.findOne({
|
||||||
|
where: { id: bayId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bay) {
|
||||||
|
throw new Error(`Work bay with ID ${bayId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the order's bay reference if there is one
|
||||||
|
if (bay.currentOrderId) {
|
||||||
|
const order = await this.orderRepository.findOne({
|
||||||
|
where: { id: bay.currentOrderId, tenantId },
|
||||||
|
});
|
||||||
|
if (order && order.bayId === bayId) {
|
||||||
|
order.bayId = undefined;
|
||||||
|
await this.orderRepository.save(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bay.status = BayStatus.AVAILABLE;
|
||||||
|
bay.currentOrderId = undefined;
|
||||||
|
return this.bayRepository.save(bay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today's receptions
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
*/
|
||||||
|
async getTodayReceptions(tenantId: string): Promise<ServiceOrder[]> {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
return this.orderRepository
|
||||||
|
.createQueryBuilder('order')
|
||||||
|
.where('order.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('order.received_at >= :today', { today })
|
||||||
|
.andWhere('order.received_at < :tomorrow', { tomorrow })
|
||||||
|
.orderBy('order.received_at', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending receptions (received but not yet diagnosed)
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
*/
|
||||||
|
async getPendingReceptions(tenantId: string): Promise<ServiceOrder[]> {
|
||||||
|
return this.orderRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
status: ServiceOrderStatus.RECEIVED,
|
||||||
|
},
|
||||||
|
order: { receivedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reception statistics
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
*/
|
||||||
|
async getReceptionStats(tenantId: string): Promise<{
|
||||||
|
todayCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
availableBays: number;
|
||||||
|
totalBays: number;
|
||||||
|
averageWaitTime: number | null;
|
||||||
|
}> {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [todayCount, pendingCount, bayStats, avgWaitResult] = await Promise.all([
|
||||||
|
this.orderRepository
|
||||||
|
.createQueryBuilder('order')
|
||||||
|
.where('order.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('order.received_at >= :today', { today })
|
||||||
|
.getCount(),
|
||||||
|
this.orderRepository.count({
|
||||||
|
where: { tenantId, status: ServiceOrderStatus.RECEIVED },
|
||||||
|
}),
|
||||||
|
this.bayRepository
|
||||||
|
.createQueryBuilder('bay')
|
||||||
|
.select('COUNT(*)', 'total')
|
||||||
|
.addSelect('SUM(CASE WHEN bay.status = :available THEN 1 ELSE 0 END)', 'available')
|
||||||
|
.where('bay.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('bay.is_active = true')
|
||||||
|
.setParameter('available', BayStatus.AVAILABLE)
|
||||||
|
.getRawOne(),
|
||||||
|
this.orderRepository
|
||||||
|
.createQueryBuilder('order')
|
||||||
|
.select('AVG(EXTRACT(EPOCH FROM (order.started_at - order.received_at)))', 'avgWait')
|
||||||
|
.where('order.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('order.started_at IS NOT NULL')
|
||||||
|
.andWhere('order.received_at >= :thirtyDaysAgo', {
|
||||||
|
thirtyDaysAgo: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||||
|
})
|
||||||
|
.getRawOne(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const avgWaitSeconds = parseFloat(avgWaitResult?.avgWait) || null;
|
||||||
|
const averageWaitTime = avgWaitSeconds ? Math.round(avgWaitSeconds / 60) : null; // Convert to minutes
|
||||||
|
|
||||||
|
return {
|
||||||
|
todayCount,
|
||||||
|
pendingCount,
|
||||||
|
availableBays: parseInt(bayStats?.available || '0', 10),
|
||||||
|
totalBays: parseInt(bayStats?.total || '0', 10),
|
||||||
|
averageWaitTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for customer by phone or name for quick lookup during reception
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param query - Search query
|
||||||
|
*/
|
||||||
|
async searchCustomer(tenantId: string, query: string): Promise<Customer[]> {
|
||||||
|
return this.customerRepository
|
||||||
|
.createQueryBuilder('customer')
|
||||||
|
.where('customer.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('customer.is_active = true')
|
||||||
|
.andWhere(
|
||||||
|
'(customer.name ILIKE :query OR customer.phone ILIKE :query OR customer.email ILIKE :query)',
|
||||||
|
{ query: `%${query}%` }
|
||||||
|
)
|
||||||
|
.orderBy('customer.name', 'ASC')
|
||||||
|
.take(10)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for vehicle by plate or VIN for quick lookup during reception
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param query - Search query (plate or VIN)
|
||||||
|
*/
|
||||||
|
async searchVehicle(tenantId: string, query: string): Promise<Vehicle[]> {
|
||||||
|
return this.vehicleRepository
|
||||||
|
.createQueryBuilder('vehicle')
|
||||||
|
.where('vehicle.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('vehicle.status = :status', { status: VehicleStatus.ACTIVE })
|
||||||
|
.andWhere(
|
||||||
|
'(vehicle.license_plate ILIKE :query OR vehicle.vin ILIKE :query OR vehicle.economic_number ILIKE :query)',
|
||||||
|
{ query: `%${query}%` }
|
||||||
|
)
|
||||||
|
.orderBy('vehicle.license_plate', 'ASC')
|
||||||
|
.take(10)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get customer's vehicles for selection during reception
|
||||||
|
*
|
||||||
|
* @param tenantId - Tenant ID
|
||||||
|
* @param customerId - Customer ID
|
||||||
|
*/
|
||||||
|
async getCustomerVehicles(tenantId: string, customerId: string): Promise<Vehicle[]> {
|
||||||
|
return this.vehicleRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
customerId,
|
||||||
|
status: VehicleStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
order: { licensePlate: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -78,7 +78,7 @@ export function requirePerm(code: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = req.user.roles || [];
|
const roles = req.user.role ? [req.user.role] : [];
|
||||||
const hasPermission = await userHasPermission(roles, resource, action);
|
const hasPermission = await userHasPermission(roles, resource, action);
|
||||||
|
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
@ -109,7 +109,7 @@ export function requireAnyPerm(...codes: string[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = req.user.roles || [];
|
const roles = req.user.role ? [req.user.role] : [];
|
||||||
|
|
||||||
// Super admin bypass
|
// Super admin bypass
|
||||||
if (roles.includes('super_admin')) {
|
if (roles.includes('super_admin')) {
|
||||||
@ -149,7 +149,7 @@ export function requireAllPerms(...codes: string[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = req.user.roles || [];
|
const roles = req.user.role ? [req.user.role] : [];
|
||||||
|
|
||||||
// Super admin bypass
|
// Super admin bypass
|
||||||
if (roles.includes('super_admin')) {
|
if (roles.includes('super_admin')) {
|
||||||
@ -198,7 +198,7 @@ export function requireAccess(options: { roles?: string[]; permission?: string }
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = req.user.roles || [];
|
const roles = req.user.role ? [req.user.role] : [];
|
||||||
|
|
||||||
// Super admin bypass
|
// Super admin bypass
|
||||||
if (roles.includes('super_admin')) {
|
if (roles.includes('super_admin')) {
|
||||||
@ -256,8 +256,8 @@ export function requirePermOrOwner(code: string, ownerField: string = 'userId')
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = req.user.sub || req.user.userId;
|
const userId = req.user.userId;
|
||||||
const roles = req.user.roles || [];
|
const roles = req.user.role ? [req.user.role] : [];
|
||||||
const resourceOwnerId = req.params[ownerField] || req.body?.[ownerField];
|
const resourceOwnerId = req.params[ownerField] || req.body?.[ownerField];
|
||||||
|
|
||||||
// Super admin bypass
|
// Super admin bypass
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user