diff --git a/app/Filament/Tenant/Pages/MechanicKpi.php b/app/Filament/Tenant/Pages/MechanicKpi.php new file mode 100644 index 0000000..adebe61 --- /dev/null +++ b/app/Filament/Tenant/Pages/MechanicKpi.php @@ -0,0 +1,87 @@ +period = now()->format('Y-m'); + } + + public function shiftMonth(int $delta): void + { + $this->period = Carbon::parse($this->period . '-01')->addMonths($delta)->format('Y-m'); + } + + public function getRows(): array + { + [$y, $m] = explode('-', $this->period); + + $rows = WorkOrderWork::query() + ->with('workOrder:id,master_id') + ->where('mechanic_status', 'done') + ->whereYear('mechanic_done_at', $y) + ->whereMonth('mechanic_done_at', $m) + ->get() + ->groupBy(fn ($w) => $w->workOrder?->master_id ?: 0); + + $masters = User::whereIn('id', $rows->keys()->all())->get(['id', 'name'])->keyBy('id'); + + $out = []; + foreach ($rows as $masterId => $works) { + if (! $masterId) continue; + $totalNorm = (float) $works->sum('hours'); + $totalActual = (float) $works->sum('actual_hours'); + $efficiencyPct = $totalNorm > 0 ? round(100 * $totalActual / $totalNorm) : null; + $cls = match (true) { + $efficiencyPct === null => 'gray', + $efficiencyPct <= 100 => 'green', + $efficiencyPct <= 130 => 'amber', + default => 'red', + }; + $out[] = [ + 'master_id' => $masterId, + 'master_name' => $masters[$masterId]?->name ?? 'Mecanic #' . $masterId, + 'tasks_done' => $works->count(), + 'norm_hours' => round($totalNorm, 2), + 'actual_hours' => round($totalActual, 2), + 'efficiency_pct' => $efficiencyPct, + 'efficiency_class' => $cls, + 'revenue' => round((float) $works->sum('total'), 2), + ]; + } + + usort($out, fn ($a, $b) => $b['revenue'] <=> $a['revenue']); + return $out; + } + + public function getPeriodLabel(): string + { + return Carbon::parse($this->period . '-01')->locale('ro')->isoFormat('MMMM YYYY'); + } +} diff --git a/app/Filament/Tenant/Resources/PricingCoefficientResource.php b/app/Filament/Tenant/Resources/PricingCoefficientResource.php index 839b772..6d22423 100644 --- a/app/Filament/Tenant/Resources/PricingCoefficientResource.php +++ b/app/Filament/Tenant/Resources/PricingCoefficientResource.php @@ -58,6 +58,16 @@ class PricingCoefficientResource extends Resource ->options(PricingCoefficient::VEHICLE_CLASSES) ->columns(2) ->columnSpanFull(), + Forms\Components\CheckboxList::make('conditions.body_types') + ->label('Caroserie') + ->options(\App\Models\Tenant\Vehicle::BODY_TYPES) + ->columns(3) + ->columnSpanFull(), + Forms\Components\CheckboxList::make('conditions.transmissions') + ->label('Cutie de viteze') + ->options(\App\Models\Tenant\Vehicle::TRANSMISSION_TYPES) + ->columns(3) + ->columnSpanFull(), Forms\Components\TextInput::make('conditions.age_min')->label('Vârstă min (ani)')->numeric(), Forms\Components\TextInput::make('conditions.age_max')->label('Vârstă max (ani)')->numeric(), Forms\Components\Toggle::make('conditions.client_vip')->label('Doar clienți VIP'), diff --git a/app/Http/Controllers/Api/MechanicApiController.php b/app/Http/Controllers/Api/MechanicApiController.php new file mode 100644 index 0000000..6867ac3 --- /dev/null +++ b/app/Http/Controllers/Api/MechanicApiController.php @@ -0,0 +1,127 @@ +id(); + $wos = WorkOrder::with(['client:id,name', 'vehicle:id,plate,make,model', 'works']) + ->where('master_id', $userId) + ->whereNotIn('status', ['done', 'cancelled']) + ->orderBy('opened_at') + ->get() + ->map(fn ($wo) => [ + 'id' => $wo->id, 'number' => $wo->number, 'status' => $wo->status, + 'client_name' => $wo->client?->name, + 'vehicle' => trim(($wo->vehicle?->make ?? '') . ' ' . ($wo->vehicle?->model ?? '')), + 'plate' => $wo->vehicle?->plate, + 'complaint' => $wo->complaint, + 'eta_at' => $wo->eta_at?->toIso8601String(), + 'works' => $wo->works->map(fn ($w) => $this->workPayload($w))->all(), + ]); + return response()->json(['data' => $wos]); + } + + /** POST /api/v1/mechanic/tasks/{work}/start */ + public function startTask(WorkOrderWork $work): JsonResponse + { + $this->authorizeOwn($work); + $work->start(); + return response()->json(['data' => $this->workPayload($work->fresh())]); + } + + public function pauseTask(WorkOrderWork $work): JsonResponse + { + $this->authorizeOwn($work); + $work->pause(); + return response()->json(['data' => $this->workPayload($work->fresh())]); + } + + public function resumeTask(WorkOrderWork $work): JsonResponse + { + $this->authorizeOwn($work); + $work->resume(); + return response()->json(['data' => $this->workPayload($work->fresh())]); + } + + public function doneTask(WorkOrderWork $work): JsonResponse + { + $this->authorizeOwn($work); + $work->markDone(); + return response()->json(['data' => $this->workPayload($work->fresh())]); + } + + public function blockTask(Request $request, WorkOrderWork $work): JsonResponse + { + $this->authorizeOwn($work); + $data = $request->validate([ + 'reason' => 'required|in:' . implode(',', array_keys(WorkOrderWork::BLOCK_REASONS)), + 'note' => 'nullable|string|max:1000', + ]); + $work->block($data['reason'], $data['note'] ?? null); + return response()->json(['data' => $this->workPayload($work->fresh())]); + } + + /** GET /api/v1/mechanic/kpi?period=2026-06 — own efficiency aggregates. */ + public function kpi(Request $request): JsonResponse + { + $userId = auth()->id(); + $period = $request->query('period', now()->format('Y-m')); + [$y, $m] = explode('-', $period); + + $rows = WorkOrderWork::whereHas('workOrder', fn ($q) => $q->where('master_id', $userId)) + ->where('mechanic_status', 'done') + ->whereYear('mechanic_done_at', $y) + ->whereMonth('mechanic_done_at', $m) + ->get(); + + $totalNorm = (float) $rows->sum('hours'); + $totalActual = (float) $rows->sum('actual_hours'); + $tasksDone = $rows->count(); + $totalRevenue = (float) $rows->sum('total'); + $efficiencyPct = $totalNorm > 0 ? round(100 * $totalActual / $totalNorm) : null; + + return response()->json([ + 'period' => $period, + 'tasks_done' => $tasksDone, + 'norm_hours' => round($totalNorm, 2), + 'actual_hours' => round($totalActual, 2), + 'efficiency_pct' => $efficiencyPct, + 'revenue_manopere' => round($totalRevenue, 2), + ]); + } + + private function workPayload(WorkOrderWork $w): array + { + return [ + 'id' => $w->id, + 'name' => $w->name, + 'mechanic_status' => $w->mechanic_status, + 'norm_hours' => (float) $w->hours, + 'actual_hours' => (float) $w->actual_hours, + 'efficiency_pct' => $w->efficiencyPct(), + 'efficiency_class' => $w->efficiencyClass(), + 'block_reason' => $w->block_reason, + 'block_note' => $w->block_note, + 'mechanic_started_at' => $w->mechanic_started_at?->toIso8601String(), + 'mechanic_done_at' => $w->mechanic_done_at?->toIso8601String(), + ]; + } + + private function authorizeOwn(WorkOrderWork $work): void + { + if ($work->workOrder?->master_id !== auth()->id()) { + abort(403, 'Work belongs to a different mechanic.'); + } + } +} diff --git a/app/Http/Controllers/TrackingController.php b/app/Http/Controllers/TrackingController.php index a741aa9..0156ad3 100644 --- a/app/Http/Controllers/TrackingController.php +++ b/app/Http/Controllers/TrackingController.php @@ -79,6 +79,72 @@ class TrackingController extends Controller return redirect()->route('tracking.show', ['token' => $token]); } + /** + * GET /api/track/{token} — JSON status payload for native apps. + * Public, no auth (token IS the credential). Tenant-scoped via subdomain. + */ + public function jsonStatus(Request $request, string $token) + { + $tenant = app(TenantManager::class)->current(); + if (! $tenant) { + return response()->json(['error' => 'tenant_required'], 404); + } + $wo = WorkOrder::with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name', 'works', 'parts']) + ->where('tracking_token', $token) + ->first(); + if (! $wo) return response()->json(['error' => 'not_found'], 404); + + $statuses = WorkOrder::STATUSES; + $flow = ['new', 'diagnosis', 'agreement', 'approved', 'in_work', 'awaiting_parts', 'ready', 'done']; + $currentIdx = array_search($wo->status, $flow, true); + + $pendingApprovals = collect() + ->merge($wo->works->filter(fn ($w) => $w->isPendingApproval())->map(fn ($w) => [ + 'kind' => 'work', 'id' => $w->id, 'token' => $w->approval_token, + 'name' => $w->name, 'amount' => (float) $w->total, + 'approve_url' => url("/t/{$token}/approve/work/{$w->approval_token}"), + ])) + ->merge($wo->parts->filter(fn ($p) => $p->isPendingApproval())->map(fn ($p) => [ + 'kind' => 'part', 'id' => $p->id, 'token' => $p->approval_token, + 'name' => $p->name, 'amount' => (float) $p->total, + 'approve_url' => url("/t/{$token}/approve/part/{$p->approval_token}"), + ])); + + // Timeline from activity_log (best-effort — empty array if not configured) + $timeline = []; + try { + $timeline = \DB::table('activity_log') + ->where('subject_type', WorkOrder::class) + ->where('subject_id', $wo->id) + ->orderBy('created_at') + ->limit(20) + ->get(['event', 'description', 'created_at']) + ->map(fn ($r) => [ + 'event' => $r->event, + 'description' => $r->description, + 'at' => $r->created_at, + ])->toArray(); + } catch (\Throwable $e) { /* activity_log table may not exist in some tenants */ } + + return response()->json([ + 'number' => $wo->number, + 'status' => $wo->status, + 'status_label' => $statuses[$wo->status] ?? $wo->status, + 'progress' => $currentIdx !== false ? round(100 * ($currentIdx + 1) / count($flow)) : null, + 'client' => $wo->client?->name, + 'vehicle' => trim(($wo->vehicle?->make ?? '') . ' ' . ($wo->vehicle?->model ?? '')), + 'plate' => $wo->vehicle?->plate, + 'master' => $wo->master?->name, + 'eta_promised' => $wo->eta_promised?->toIso8601String(), + 'eta_current' => $wo->eta_at?->toIso8601String(), + 'eta_change_reason' => $wo->eta_change_reason, + 'total' => (float) $wo->total, + 'pay_status' => $wo->pay_status, + 'pending_approvals' => $pendingApprovals->values(), + 'timeline' => $timeline, + ]); + } + public function qr(Request $request, string $token) { $tenant = app(TenantManager::class)->current(); diff --git a/app/Jobs/ProcessOcrJob.php b/app/Jobs/ProcessOcrJob.php new file mode 100644 index 0000000..2c2d97e --- /dev/null +++ b/app/Jobs/ProcessOcrJob.php @@ -0,0 +1,60 @@ +companyId); + if (! $company) { return; } + $tenants->setCurrent($company); + + $job = OcrJob::find($this->ocrJobId); + if (! $job) return; + + $job->update(['status' => 'processing']); + + try { + $absPath = Storage::disk('local')->path($job->file_path); + $result = $svc->extract($absPath); + $job->update([ + 'status' => 'done', + 'result' => $result, + 'processed_at' => now(), + 'ai_provider' => 'claude', + ]); + } catch (\Throwable $e) { + $job->update([ + 'status' => 'failed', + 'error_message' => $e->getMessage(), + 'processed_at' => now(), + ]); + throw $e; + } + } + + public function failed(\Throwable $e): void + { + $job = OcrJob::find($this->ocrJobId); + $job?->update(['status' => 'failed', 'error_message' => $e->getMessage()]); + } +} diff --git a/app/Models/Tenant/ClientNotificationLog.php b/app/Models/Tenant/ClientNotificationLog.php new file mode 100644 index 0000000..9de141a --- /dev/null +++ b/app/Models/Tenant/ClientNotificationLog.php @@ -0,0 +1,44 @@ + 'SMS', + 'whatsapp' => 'WhatsApp', + 'telegram' => 'Telegram', + 'email' => 'Email', + 'push' => 'Web Push', + ]; + + public const STATUSES = [ + 'sent' => 'Trimis', + 'delivered' => 'Livrat', + 'failed' => 'Eșuat', + 'read' => 'Citit', + ]; + + protected $fillable = [ + 'company_id', 'work_order_id', 'client_id', + 'channel', 'template_key', 'message_text', 'status', 'error_detail', + 'sent_at', 'delivered_at', + ]; + + protected $casts = [ + 'sent_at' => 'datetime', + 'delivered_at' => 'datetime', + ]; + + public function workOrder(): BelongsTo { return $this->belongsTo(WorkOrder::class); } + public function client(): BelongsTo { return $this->belongsTo(Client::class); } +} diff --git a/app/Models/Tenant/OcrJob.php b/app/Models/Tenant/OcrJob.php new file mode 100644 index 0000000..caa966b --- /dev/null +++ b/app/Models/Tenant/OcrJob.php @@ -0,0 +1,34 @@ + 'În așteptare', + 'processing' => 'Procesare', + 'done' => 'Finalizat', + 'failed' => 'Eșuat', + ]; + + protected $fillable = [ + 'company_id', 'supplier_id', 'source_type', 'file_path', 'status', + 'result', 'error_message', 'ai_provider', 'tokens_used', + 'purchase_id', 'processed_at', + ]; + + protected $casts = [ + 'result' => 'array', + 'processed_at' => 'datetime', + 'tokens_used' => 'integer', + ]; + + public function supplier(): BelongsTo { return $this->belongsTo(Supplier::class); } + public function purchase(): BelongsTo { return $this->belongsTo(Purchase::class); } +} diff --git a/app/Models/Tenant/PricingApplicationLog.php b/app/Models/Tenant/PricingApplicationLog.php new file mode 100644 index 0000000..53a3fcc --- /dev/null +++ b/app/Models/Tenant/PricingApplicationLog.php @@ -0,0 +1,37 @@ + 'decimal:2', + 'final_price' => 'decimal:2', + 'applied_coefficients' => 'array', + 'context' => 'array', + 'calculated_at' => 'datetime', + ]; + + public function subject(): MorphTo { return $this->morphTo(); } + public function part(): BelongsTo { return $this->belongsTo(Part::class); } + public function vehicle(): BelongsTo { return $this->belongsTo(Vehicle::class); } + public function client(): BelongsTo { return $this->belongsTo(Client::class); } +} diff --git a/app/Models/Tenant/PricingCoefficient.php b/app/Models/Tenant/PricingCoefficient.php index e01e642..212fd12 100644 --- a/app/Models/Tenant/PricingCoefficient.php +++ b/app/Models/Tenant/PricingCoefficient.php @@ -53,6 +53,22 @@ class PricingCoefficient extends Model } } + // Body type — sedan|suv|pickup|... + $bodyTypes = (array) ($c['body_types'] ?? []); + if (! empty($bodyTypes)) { + if (empty($ctx['body_type']) || ! in_array($ctx['body_type'], $bodyTypes, true)) { + return false; + } + } + + // Transmission — dsg|cvt|automatic|... + $transmissions = (array) ($c['transmissions'] ?? []); + if (! empty($transmissions)) { + if (empty($ctx['transmission']) || ! in_array($ctx['transmission'], $transmissions, true)) { + return false; + } + } + // Vehicle age range. if (isset($c['age_min']) && $c['age_min'] !== null && $c['age_min'] !== '') { if (($ctx['age'] ?? null) === null || $ctx['age'] < (int) $c['age_min']) return false; diff --git a/app/Models/Tenant/Vehicle.php b/app/Models/Tenant/Vehicle.php index 338c902..b214719 100644 --- a/app/Models/Tenant/Vehicle.php +++ b/app/Models/Tenant/Vehicle.php @@ -12,10 +12,27 @@ class Vehicle extends Model { use Auditable, BelongsToTenant, SoftDeletes; + public const BODY_TYPES = [ + 'sedan' => 'Sedan', 'hatchback' => 'Hatchback', 'suv' => 'SUV', + 'crossover' => 'Crossover', 'pickup' => 'Pickup', 'van' => 'Van', + 'truck' => 'Camion', 'coupe' => 'Coupé', 'wagon' => 'Break', + 'convertible' => 'Cabrio', 'minivan' => 'Minivan', 'moto' => 'Motocicletă', + ]; + + public const TRANSMISSION_TYPES = [ + 'manual' => 'Manuală', + 'automatic' => 'Automată', + 'cvt' => 'CVT', + 'dsg' => 'DSG', + 'dct' => 'DCT (Dual-Clutch)', + 'amt' => 'AMT (Robot)', + ]; + protected $fillable = [ 'company_id', 'client_id', 'make', 'model', 'year', 'vin', 'plate', - 'engine', 'gearbox', 'fuel', 'vehicle_class', 'mileage', 'color', 'notes', + 'engine', 'gearbox', 'fuel', 'vehicle_class', 'body_type', 'transmission_type', + 'mileage', 'color', 'notes', ]; public function client(): BelongsTo diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index d13c4ab..5a17cad 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -40,7 +40,8 @@ class WorkOrder extends Model implements HasMedia 'complaint', 'diagnosis', 'recommendations', 'status', 'urgency', 'pay_status', 'approved', 'approved_at', 'discount_pct', 'total', - 'eta_at', 'tracking_token', + 'eta_at', 'eta_promised', 'eta_change_reason', 'eta_updated_at', + 'tracking_token', ]; protected $casts = [ @@ -48,6 +49,8 @@ class WorkOrder extends Model implements HasMedia 'closed_at' => 'date', 'approved_at' => 'datetime', 'eta_at' => 'datetime', + 'eta_promised' => 'datetime', + 'eta_updated_at' => 'datetime', 'approved' => 'boolean', 'discount_pct' => 'decimal:2', 'total' => 'decimal:2', diff --git a/app/Services/NotificationDispatcher.php b/app/Services/NotificationDispatcher.php index d011c37..adc50c7 100644 --- a/app/Services/NotificationDispatcher.php +++ b/app/Services/NotificationDispatcher.php @@ -47,7 +47,7 @@ class NotificationDispatcher fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)), 'workOrderReady', ['wo' => $wo->id] ), - ]); + ], workOrderId: $wo->id); } public function paymentReceived(Payment $payment): bool @@ -62,7 +62,7 @@ class NotificationDispatcher fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)), 'paymentReceived', ['payment' => $payment->id] ), - ]); + ], workOrderId: $payment->work_order_id); } public function appointmentConfirmed(Appointment $a): bool @@ -138,24 +138,43 @@ class NotificationDispatcher * @param array $senders channel-key → sender callback * @return bool Returns the channel name that delivered, or null on full miss. */ - protected function dispatch(Company $company, Client $client, string $key, array $senders): bool + protected function dispatch(Company $company, Client $client, string $key, array $senders, ?int $workOrderId = null): bool { $any = false; foreach ($this->channelsFor($company, $client, $key) as $channel) { if (! isset($senders[$channel])) continue; try { - if (($senders[$channel])() === true) { + $ok = ($senders[$channel])() === true; + $this->logNotification($company->id, $workOrderId, $client->id, $channel, $key, $ok); + if ($ok) { $any = true; - // Try only one channel — first that succeeds is enough. break; } } catch (\Throwable $e) { Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]); + $this->logNotification($company->id, $workOrderId, $client->id, $channel, $key, false, $e->getMessage()); } } return $any; } + /** Append-only log entry — never throw from here, swallow DB errors. */ + protected function logNotification(int $companyId, ?int $workOrderId, ?int $clientId, string $channel, string $key, bool $success, ?string $error = null): void + { + try { + \App\Models\Tenant\ClientNotificationLog::create([ + 'company_id' => $companyId, + 'work_order_id' => $workOrderId, + 'client_id' => $clientId, + 'channel' => $channel, + 'template_key' => $key, + 'status' => $success ? 'sent' : 'failed', + 'error_detail' => $error, + 'sent_at' => now(), + ]); + } catch (\Throwable $e) { /* never break sending because of logging */ } + } + /** * Resolve which channels to try and in what order, applying per-client * preference if set, otherwise the tenant default. diff --git a/app/Services/Pricing/PricingEngine.php b/app/Services/Pricing/PricingEngine.php index 244eae8..b51d767 100644 --- a/app/Services/Pricing/PricingEngine.php +++ b/app/Services/Pricing/PricingEngine.php @@ -33,6 +33,8 @@ class PricingEngine $ctx = [ 'class' => $this->vehicleClass($vehicle), 'age' => $this->vehicleAge($vehicle), + 'body_type' => $vehicle?->body_type, + 'transmission' => $vehicle?->transmission_type, 'vip' => (bool) ($client?->is_vip), 'urgency' => $urgency ?: 'normal', ]; @@ -60,11 +62,35 @@ class PricingEngine $applied[] = ['name' => $nonStack->name, 'multiplier' => (float) $nonStack->multiplier]; } - return [ + $result = [ 'base' => round($base, 2), 'final' => round($base * $factor, 2), 'applied' => $applied, + 'context' => $ctx, ]; + + return $result; + } + + /** + * Persist a quote to pricing_application_logs — appends one immutable row + * per pricing decision. Caller passes the subject (WO part/work line) so + * we can later answer "why was this line priced at X?". + */ + public function logApplication(array $quote, $subject, ?Vehicle $vehicle = null, ?Client $client = null, ?Part $part = null): \App\Models\Tenant\PricingApplicationLog + { + return \App\Models\Tenant\PricingApplicationLog::create([ + 'subject_type' => get_class($subject), + 'subject_id' => $subject->id ?? 0, + 'part_id' => $part?->id, + 'vehicle_id' => $vehicle?->id, + 'client_id' => $client?->id, + 'base_price' => $quote['base'], + 'final_price' => $quote['final'], + 'applied_coefficients' => $quote['applied'], + 'context' => $quote['context'] ?? [], + 'calculated_at' => now(), + ]); } private function basePrice(Part $part): float diff --git a/database/migrations/2026_06_05_000004_polish_m12_m13_m14_m15.php b/database/migrations/2026_06_05_000004_polish_m12_m13_m14_m15.php new file mode 100644 index 0000000..a0c8594 --- /dev/null +++ b/database/migrations/2026_06_05_000004_polish_m12_m13_m14_m15.php @@ -0,0 +1,103 @@ +string('body_type', 16)->nullable()->after('vehicle_class'); + // sedan | hatchback | suv | crossover | pickup | van | truck | coupe | wagon | convertible | minivan | moto + } + if (! Schema::hasColumn('vehicles', 'transmission_type')) { + $t->string('transmission_type', 16)->nullable()->after('body_type'); + // manual | automatic | cvt | dsg | dct | amt | robot + } + }); + + // M12: pricing application audit log + Schema::create('pricing_application_logs', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->morphs('subject'); // WorkOrderPart or WorkOrderWork + $t->foreignId('part_id')->nullable()->constrained('parts')->nullOnDelete(); + $t->foreignId('vehicle_id')->nullable()->constrained('vehicles')->nullOnDelete(); + $t->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete(); + $t->decimal('base_price', 12, 2); + $t->decimal('final_price', 12, 2); + $t->json('applied_coefficients'); // [{name, multiplier, type}, ...] + $t->json('context'); // {class, age, body_type, transmission, vip, urgency} + $t->timestamp('calculated_at')->useCurrent(); + }); + + // M14: ocr_jobs queue + Schema::create('ocr_jobs', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('supplier_id')->nullable()->constrained('suppliers')->nullOnDelete(); + $t->string('source_type', 16); // pdf | image | xlsx | barcode_scan + $t->string('file_path', 500)->nullable(); // storage path + $t->string('status', 16)->default('pending'); // pending|processing|done|failed + $t->json('result')->nullable(); + $t->text('error_message')->nullable(); + $t->string('ai_provider', 32)->nullable(); + $t->integer('tokens_used')->nullable(); + $t->foreignId('purchase_id')->nullable()->constrained('purchases')->nullOnDelete(); + $t->timestamp('processed_at')->nullable(); + $t->timestamps(); + $t->index(['company_id', 'status']); + }); + + // M15: eta_promised distinct from eta_at + change reason audit + Schema::table('work_orders', function (Blueprint $t) { + if (! Schema::hasColumn('work_orders', 'eta_promised')) { + $t->timestamp('eta_promised')->nullable()->after('eta_at'); + } + if (! Schema::hasColumn('work_orders', 'eta_change_reason')) { + $t->string('eta_change_reason', 255)->nullable()->after('eta_promised'); + } + if (! Schema::hasColumn('work_orders', 'eta_updated_at')) { + $t->timestamp('eta_updated_at')->nullable()->after('eta_change_reason'); + } + }); + + // M15: client notifications log + Schema::create('client_notifications_log', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('work_order_id')->nullable()->constrained('work_orders')->nullOnDelete(); + $t->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete(); + $t->string('channel', 16); // sms | whatsapp | telegram | email | push + $t->string('template_key', 64); // wo_ready | eta_updated | approval_needed | service_reminder + $t->text('message_text')->nullable(); + $t->string('status', 16)->default('sent'); // sent | delivered | failed | read + $t->text('error_detail')->nullable(); + $t->timestamp('sent_at')->useCurrent(); + $t->timestamp('delivered_at')->nullable(); + $t->index(['company_id', 'sent_at']); + $t->index(['work_order_id', 'sent_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('client_notifications_log'); + Schema::dropIfExists('ocr_jobs'); + Schema::dropIfExists('pricing_application_logs'); + Schema::table('vehicles', function (Blueprint $t) { + foreach (['body_type', 'transmission_type'] as $col) { + if (Schema::hasColumn('vehicles', $col)) $t->dropColumn($col); + } + }); + Schema::table('work_orders', function (Blueprint $t) { + foreach (['eta_promised', 'eta_change_reason', 'eta_updated_at'] as $col) { + if (Schema::hasColumn('work_orders', $col)) $t->dropColumn($col); + } + }); + } +}; diff --git a/resources/views/filament/tenant/pages/mechanic-kpi.blade.php b/resources/views/filament/tenant/pages/mechanic-kpi.blade.php new file mode 100644 index 0000000..43bd184 --- /dev/null +++ b/resources/views/filament/tenant/pages/mechanic-kpi.blade.php @@ -0,0 +1,55 @@ + +@php $rows = $this->getRows(); @endphp + + +
+ +
{{ $this->getPeriodLabel() }}
+ +
+ +@if (empty($rows)) +
Niciun mecanic nu a finalizat lucrări în această perioadă.
+@else + + + + + + + + + + + + + @foreach ($rows as $r) + + + + + + + + + @endforeach + +
MecanicLucrăriNorma oreReal oreEficiențăVenit manopere
{{ $r['master_name'] }}{{ $r['tasks_done'] }}{{ $r['norm_hours'] }}{{ $r['actual_hours'] }}@if ($r['efficiency_pct'] !== null){{ $r['efficiency_pct'] }}%@else @endif{{ number_format($r['revenue'], 0, '.', ' ') }} MDL
+@endif +
diff --git a/routes/api.php b/routes/api.php index cbd38bf..3cda2d8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,12 +2,19 @@ use App\Http\Controllers\Api\ApiAuthController; use App\Http\Controllers\Api\ClientApiController; +use App\Http\Controllers\Api\MechanicApiController; use App\Http\Controllers\Api\RoleApiController; use App\Http\Controllers\Api\UserApiController; use App\Http\Controllers\Api\VehicleApiController; use App\Http\Controllers\Api\WorkOrderApiController; +use App\Http\Controllers\TrackingController; use Illuminate\Support\Facades\Route; +// Public tracking JSON (no auth) +Route::get('/track/{token}', [TrackingController::class, 'jsonStatus']) + ->where('token', '[A-Za-z0-9]{16,32}') + ->name('api.tracking.json'); + // Laravel 12 auto-prefixes routes/api.php with /api → routes here become /api/v1/... Route::prefix('v1')->group(function () { Route::post('/login', [ApiAuthController::class, 'login']); @@ -40,5 +47,14 @@ Route::prefix('v1')->group(function () { Route::get('roles/{role}/permissions', [RoleApiController::class, 'permissions']); Route::put('roles/{role}/permissions', [RoleApiController::class, 'syncPermissions']); Route::get('permissions', [RoleApiController::class, 'permissionCatalog']); + + // M13 — mechanic-scoped board + KPI + Route::get('mechanic/board', [MechanicApiController::class, 'board']); + Route::get('mechanic/kpi', [MechanicApiController::class, 'kpi']); + Route::post('mechanic/tasks/{work}/start', [MechanicApiController::class, 'startTask']); + Route::post('mechanic/tasks/{work}/pause', [MechanicApiController::class, 'pauseTask']); + Route::post('mechanic/tasks/{work}/resume', [MechanicApiController::class, 'resumeTask']); + Route::post('mechanic/tasks/{work}/done', [MechanicApiController::class, 'doneTask']); + Route::post('mechanic/tasks/{work}/block', [MechanicApiController::class, 'blockTask']); }); }); diff --git a/tests/Feature/PolishTier3Test.php b/tests/Feature/PolishTier3Test.php new file mode 100644 index 0000000..9b58342 --- /dev/null +++ b/tests/Feature/PolishTier3Test.php @@ -0,0 +1,212 @@ + 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $this->company = Company::create(['plan_id' => $plan->id, 'slug' => 't3-' . uniqid(), 'name' => 'T3', 'status' => 'active']); + app(TenantManager::class)->setCurrent($this->company); + } + + private function makeClient(string $prefix = ''): Client + { + return Client::create(['name' => $prefix . 'C', 'phone' => '+3739900' . random_int(1000, 9999), 'type' => 'individual', 'status' => 'active']); + } + + // ── M12 ── + public function test_pricing_engine_matches_body_type_condition(): void + { + PricingCoefficient::create([ + 'name' => 'Pickup +20%', 'multiplier' => 1.20, + 'conditions' => ['body_types' => ['pickup']], + 'stackable' => true, 'priority' => 100, 'is_active' => true, + ]); + $part = Part::create(['name' => 'P', 'article' => 'X', 'buy_price' => 100, 'sell_price' => 100]); + $pickup = Vehicle::create(['client_id' => $this->makeClient('p')->id, 'make' => 'Ford', 'model' => 'Ranger', 'plate' => 'P1', 'body_type' => 'pickup']); + $sedan = Vehicle::create(['client_id' => $this->makeClient('s')->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'S1', 'body_type' => 'sedan']); + + $q1 = app(PricingEngine::class)->quote($part, $pickup); + $q2 = app(PricingEngine::class)->quote($part, $sedan); + + $this->assertCount(1, $q1['applied']); + $this->assertEmpty($q2['applied']); + } + + public function test_pricing_engine_matches_transmission_dsg(): void + { + PricingCoefficient::create([ + 'name' => 'DSG +15%', 'multiplier' => 1.15, + 'conditions' => ['transmissions' => ['dsg']], + 'stackable' => true, 'priority' => 100, 'is_active' => true, + ]); + $part = Part::create(['name' => 'P', 'article' => 'X', 'buy_price' => 100, 'sell_price' => 100]); + $dsg = Vehicle::create(['client_id' => $this->makeClient('d')->id, 'make' => 'VW', 'model' => 'Golf', 'plate' => 'D1', 'transmission_type' => 'dsg']); + + $q = app(PricingEngine::class)->quote($part, $dsg); + $this->assertCount(1, $q['applied']); + $this->assertEquals('DSG +15%', $q['applied'][0]['name']); + } + + public function test_pricing_log_persists_breakdown(): void + { + PricingCoefficient::create([ + 'name' => 'SUV +15%', 'multiplier' => 1.15, + 'conditions' => ['classes' => ['suv']], + 'stackable' => true, 'priority' => 100, 'is_active' => true, + ]); + $part = Part::create(['name' => 'P', 'article' => 'X', 'buy_price' => 100, 'sell_price' => 150]); + $client = $this->makeClient('pl'); + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'X1', 'vehicle_class' => 'suv', 'year' => 2020]); + $wo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'opened_at' => today(), 'status' => 'in_work', 'total' => 0]); + $line = \App\Models\Tenant\WorkOrderPart::create(['work_order_id' => $wo->id, 'name' => 'P', 'qty' => 1, 'sell_price' => 150]); + + $quote = app(PricingEngine::class)->quote($part, $vehicle, $client); + $log = app(PricingEngine::class)->logApplication($quote, $line, $vehicle, $client, $part); + + $this->assertEqualsWithDelta(150.0, (float) $log->base_price, 0.01); + $this->assertEqualsWithDelta(172.5, (float) $log->final_price, 0.01); + $this->assertCount(1, $log->applied_coefficients); + $this->assertEquals('suv', $log->context['class']); + } + + // ── M13 ── + public function test_mechanic_api_board_returns_only_own_wos(): void + { + $mech = User::create(['name' => 'M', 'email' => 'm@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + $other = User::create(['name' => 'O', 'email' => 'o@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'master_id' => $mech->id, 'opened_at' => today(), 'status' => 'in_work', 'total' => 100]); + WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'master_id' => $other->id, 'opened_at' => today(), 'status' => 'in_work', 'total' => 200]); + + Sanctum::actingAs($mech); + $resp = $this->getJson('/api/v1/mechanic/board'); + $resp->assertOk(); + $this->assertCount(1, $resp->json('data')); + } + + public function test_mechanic_api_start_task_only_own(): void + { + $mech = User::create(['name' => 'M', 'email' => 'm@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + $other = User::create(['name' => 'O', 'email' => 'o@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + $foreignWo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'master_id' => $other->id, 'opened_at' => today(), 'status' => 'in_work', 'total' => 0]); + $foreignWork = WorkOrderWork::create(['work_order_id' => $foreignWo->id, 'name' => "Other's", 'hours' => 1, 'price_per_hour' => 100]); + + Sanctum::actingAs($mech); + $resp = $this->postJson("/api/v1/mechanic/tasks/{$foreignWork->id}/start"); + $resp->assertForbidden(); + } + + public function test_mechanic_kpi_endpoint_aggregates_period(): void + { + $mech = User::create(['name' => 'M', 'email' => 'm@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + $wo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'master_id' => $mech->id, 'opened_at' => today(), 'status' => 'in_work', 'total' => 0]); + + // Two done works in 2026-06 + WorkOrderWork::create(['work_order_id' => $wo->id, 'name' => 'A', 'hours' => 2, 'price_per_hour' => 300, 'mechanic_status' => 'done', 'actual_hours' => 1.5, 'mechanic_done_at' => '2026-06-10 10:00:00']); + WorkOrderWork::create(['work_order_id' => $wo->id, 'name' => 'B', 'hours' => 1, 'price_per_hour' => 300, 'mechanic_status' => 'done', 'actual_hours' => 1.0, 'mechanic_done_at' => '2026-06-15 10:00:00']); + + Sanctum::actingAs($mech); + $resp = $this->getJson('/api/v1/mechanic/kpi?period=2026-06'); + $resp->assertOk(); + $this->assertEquals(2, $resp->json('tasks_done')); + $this->assertEqualsWithDelta(3.0, $resp->json('norm_hours'), 0.01); + $this->assertEqualsWithDelta(2.5, $resp->json('actual_hours'), 0.01); + $this->assertEquals(83, $resp->json('efficiency_pct')); // 2.5/3 = 83% + } + + // ── M14 ── + public function test_ocr_job_can_be_queued_and_processed(): void + { + \Queue::fake(); + $jobModel = \App\Models\Tenant\OcrJob::create([ + 'company_id' => $this->company->id, + 'source_type' => 'pdf', 'file_path' => 'imports/test.pdf', 'status' => 'pending', + ]); + + \App\Jobs\ProcessOcrJob::dispatch($jobModel->id, $this->company->id); + \Queue::assertPushed(\App\Jobs\ProcessOcrJob::class, fn ($j) => $j->ocrJobId === $jobModel->id); + } + + // ── M15 ── + public function test_tracking_json_endpoint_returns_status_payload(): void + { + $client = Client::create(['name' => 'C', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']); + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'JS-1']); + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($this->company->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, + 'opened_at' => today(), 'status' => 'in_work', 'total' => 500, + 'eta_promised' => now()->addHours(3), + 'eta_at' => now()->addHours(4), + 'eta_change_reason' => 'Aștept piesă', + ]); + + $resp = $this->getJson("/api/track/{$wo->tracking_token}"); + $resp->assertOk(); + $this->assertEquals($wo->number, $resp->json('number')); + $this->assertEquals('in_work', $resp->json('status')); + $this->assertEquals('Aștept piesă', $resp->json('eta_change_reason')); + $this->assertNotNull($resp->json('eta_promised')); + $this->assertNotNull($resp->json('eta_current')); + } + + public function test_tracking_json_returns_pending_approvals_with_signed_urls(): void + { + $client = Client::create(['name' => 'C', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']); + $wo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'opened_at' => today(), 'status' => 'in_work', 'total' => 0]); + WorkOrderWork::create(['work_order_id' => $wo->id, 'name' => 'Needs OK', 'hours' => 1, 'price_per_hour' => 200, 'requires_approval' => true]); + + $resp = $this->getJson("/api/track/{$wo->tracking_token}"); + $resp->assertOk(); + $this->assertCount(1, $resp->json('pending_approvals')); + $this->assertEquals('work', $resp->json('pending_approvals.0.kind')); + $this->assertStringContainsString('/approve/work/', $resp->json('pending_approvals.0.approve_url')); + } + + public function test_dispatcher_writes_notification_log_entry(): void + { + $client = Client::create(['name' => 'C', 'phone' => '+37399000000', 'email' => 'c@e.com', 'type' => 'individual', 'status' => 'active']); + $wo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'opened_at' => today(), 'closed_at' => today(), 'status' => 'ready', 'total' => 500]); + \Mail::fake(); + + app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo); + + $log = ClientNotificationLog::where('work_order_id', $wo->id)->first(); + $this->assertNotNull($log); + $this->assertEquals('wo_ready', $log->template_key); + $this->assertContains($log->channel, ['email', 'telegram']); + $this->assertEquals($wo->id, $log->work_order_id); + } + + public function test_vehicle_body_and_transmission_round_trip(): void + { + $v = Vehicle::create(['client_id' => $this->makeClient()->id, 'make' => 'VW', 'model' => 'Tiguan', 'plate' => 'VR-1', 'body_type' => 'crossover', 'transmission_type' => 'dsg']); + $fresh = Vehicle::find($v->id); + $this->assertEquals('crossover', $fresh->body_type); + $this->assertEquals('dsg', $fresh->transmission_type); + } +}