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:
Adrian Flores Cortes 2026-02-03 00:28:03 -06:00
parent f4ed131c1e
commit b455de93b2
8 changed files with 2894 additions and 234 deletions

View File

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

View File

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

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

View File

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

View File

@ -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' },
});
}
}

View File

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