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
|
||||
*
|
||||
* 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 { 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';
|
||||
|
||||
interface TenantRequest extends Request {
|
||||
@ -31,11 +41,63 @@ export function createDiagnosticController(dataSource: DataSource): Router {
|
||||
|
||||
router.use(extractTenant);
|
||||
|
||||
// ============================================
|
||||
// CORE DIAGNOSTIC ENDPOINTS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a new diagnostic
|
||||
* Create a new diagnostic for an order
|
||||
* 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) => {
|
||||
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 {
|
||||
const diagnostic = await service.create(req.tenantId!, req.body);
|
||||
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
|
||||
*/
|
||||
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 /api/diagnostics/vehicle/:vehicleId
|
||||
*
|
||||
* Query params:
|
||||
* - limit?: number (default: 50)
|
||||
*/
|
||||
router.get('/vehicle/:vehicleId', async (req: TenantRequest, res: Response) => {
|
||||
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);
|
||||
} catch (error) {
|
||||
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) => {
|
||||
try {
|
||||
const diagnostics = await service.findByOrder(req.tenantId!, req.params.orderId);
|
||||
const diagnostics = await service.getDiagnosticsByOrder(req.tenantId!, req.params.orderId);
|
||||
res.json(diagnostics);
|
||||
} catch (error) {
|
||||
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
|
||||
*
|
||||
* Body:
|
||||
* - result: DiagnosticResult (required)
|
||||
* - summary?: string
|
||||
*/
|
||||
router.patch('/:id/result', async (req: TenantRequest, res: Response) => {
|
||||
try {
|
||||
const { result, summary } = req.body;
|
||||
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: 'result is required' });
|
||||
}
|
||||
|
||||
const diagnostic = await service.updateResult(
|
||||
req.tenantId!,
|
||||
req.params.id,
|
||||
@ -122,28 +334,79 @@ export function createDiagnosticController(dataSource: DataSource): Router {
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse DTC codes from raw data
|
||||
* POST /api/diagnostics/parse-dtc
|
||||
* Complete a diagnostic
|
||||
* 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 {
|
||||
const items = service.parseDTCCodes(req.body.rawData || {});
|
||||
res.json(items);
|
||||
const { result, summary, triggerQuote } = req.body;
|
||||
|
||||
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) {
|
||||
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
|
||||
* POST /api/diagnostics/analyze-injectors
|
||||
* Generate diagnostic report data
|
||||
* 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 {
|
||||
const items = service.analyzeInjectorTest(req.body.rawData || {});
|
||||
res.json(items);
|
||||
const reportData = await service.generateDiagnosticReport(req.tenantId!, req.params.id);
|
||||
res.json(reportData);
|
||||
} 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
|
||||
* Mecánicas Diesel - ERP Suite
|
||||
* Mecanicas Diesel - ERP Suite
|
||||
*/
|
||||
|
||||
export * from './service-order.entity';
|
||||
export * from './order-item.entity';
|
||||
export * from './diagnostic.entity';
|
||||
export * from './quote.entity';
|
||||
export * from './quote-item.entity';
|
||||
export * from './work-bay.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 { Diagnostic, DiagnosticType, DiagnosticResult } from './entities/diagnostic.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 { 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';
|
||||
export {
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
const roles = req.user.roles || [];
|
||||
const roles = req.user.role ? [req.user.role] : [];
|
||||
const hasPermission = await userHasPermission(roles, resource, action);
|
||||
|
||||
if (hasPermission) {
|
||||
@ -109,7 +109,7 @@ export function requireAnyPerm(...codes: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = req.user.roles || [];
|
||||
const roles = req.user.role ? [req.user.role] : [];
|
||||
|
||||
// Super admin bypass
|
||||
if (roles.includes('super_admin')) {
|
||||
@ -149,7 +149,7 @@ export function requireAllPerms(...codes: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = req.user.roles || [];
|
||||
const roles = req.user.role ? [req.user.role] : [];
|
||||
|
||||
// Super admin bypass
|
||||
if (roles.includes('super_admin')) {
|
||||
@ -198,7 +198,7 @@ export function requireAccess(options: { roles?: string[]; permission?: string }
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = req.user.roles || [];
|
||||
const roles = req.user.role ? [req.user.role] : [];
|
||||
|
||||
// Super admin bypass
|
||||
if (roles.includes('super_admin')) {
|
||||
@ -256,8 +256,8 @@ export function requirePermOrOwner(code: string, ownerField: string = 'userId')
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.user.sub || req.user.userId;
|
||||
const roles = req.user.roles || [];
|
||||
const userId = req.user.userId;
|
||||
const roles = req.user.role ? [req.user.role] : [];
|
||||
const resourceOwnerId = req.params[ownerField] || req.body?.[ownerField];
|
||||
|
||||
// Super admin bypass
|
||||
|
||||
Loading…
Reference in New Issue
Block a user