From e48ef1b75589a3d0961dbd7c3199de7e91c2bd2e Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 27 May 2026 21:39:39 +0000 Subject: [PATCH] =?UTF-8?q?Stage=207+14=20=E2=80=94=20Mechanic=20Board=20+?= =?UTF-8?q?=20Scan=20Center?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanic Workflow (Stage 7): - /app/mechanic Filament page filtered to master_id = auth user - Kanban 4 columns (in_work / awaiting_parts / ready / recent), each card shows WO#, plate, client, complaint summary, photo presence - 2 KPI tiles (active now / closed today) - Mobile-responsive grid (auto-fit, minmax 260px) WarehouseService: - issueNow(WorkOrderPart) — consume reservations immediately scoped to one line, without closing the WO (mechanic physically takes part now) - returnPart(WorkOrderPart, qty?, notes?) — refund to stock as new batch at original buy_price, writes `return` event, capped at consumed total WO PartsRelationManager: - "Eliberează" action — visible when active reservation exists - "Restituire" action — visible when consumed reservation exists, with qty modal + notes Scan Center (Stage 14): - PartResource "QR" action — per-part SVG QR with payload PART: - BulkAction "Tipărește etichete QR" → /parts/labels?ids=N,M (HTML A4 sheet, 3-col grid, print CSS hides toolbar) - /app/scan Filament page using html5-qrcode 2.3.8 (CDN), auto-picks back camera, decodes → Livewire dispatches scanner-decoded → resolveAndRedirect - Lookup matches PART:N prefix, parts.article, parts.barcode, or numeric id - Manual input fallback for browsers without camera Tests (6 new): - WarehouseIssueReturnTest (3): issueNow consumes immediately; returnPart creates positive batch + return event; over-return is capped - ScannerLookupTest (3): PART: prefix lookup, raw barcode lookup, unknown miss Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Filament/Tenant/Pages/MechanicBoard.php | 85 ++++++++++ app/Filament/Tenant/Pages/Scanner.php | 79 +++++++++ .../Tenant/Resources/PartResource.php | 31 ++++ .../RelationManagers/PartsRelationManager.php | 45 ++++++ app/Http/Controllers/PartLabelsController.php | 38 +++++ app/Services/Warehouse/WarehouseService.php | 89 ++++++++++ .../tenant/pages/mechanic-board.blade.php | 83 ++++++++++ .../filament/tenant/pages/scanner.blade.php | 111 +++++++++++++ .../views/filament/tenant/part-qr.blade.php | 12 ++ resources/views/parts/labels.blade.php | 65 ++++++++ routes/web.php | 6 + tests/Feature/ScannerLookupTest.php | 74 +++++++++ tests/Feature/WarehouseIssueReturnTest.php | 153 ++++++++++++++++++ 13 files changed, 871 insertions(+) create mode 100644 app/Filament/Tenant/Pages/MechanicBoard.php create mode 100644 app/Filament/Tenant/Pages/Scanner.php create mode 100644 app/Http/Controllers/PartLabelsController.php create mode 100644 resources/views/filament/tenant/pages/mechanic-board.blade.php create mode 100644 resources/views/filament/tenant/pages/scanner.blade.php create mode 100644 resources/views/filament/tenant/part-qr.blade.php create mode 100644 resources/views/parts/labels.blade.php create mode 100644 tests/Feature/ScannerLookupTest.php create mode 100644 tests/Feature/WarehouseIssueReturnTest.php diff --git a/app/Filament/Tenant/Pages/MechanicBoard.php b/app/Filament/Tenant/Pages/MechanicBoard.php new file mode 100644 index 0000000..3dd66c4 --- /dev/null +++ b/app/Filament/Tenant/Pages/MechanicBoard.php @@ -0,0 +1,85 @@ +id()). + * Kanban-style grouped by status. + */ +class MechanicBoard extends Page +{ + protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench'; + + protected static ?string $navigationLabel = 'Atelierul meu'; + + protected static string|\UnitEnum|null $navigationGroup = 'Service'; + + protected static ?int $navigationSort = 25; + + protected static ?string $title = 'Atelierul meu'; + + protected string $view = 'filament.tenant.pages.mechanic-board'; + + public function getColumns(): array + { + $userId = auth()->id(); + if (! $userId) return []; + + $all = WorkOrder::with(['client', 'vehicle']) + ->where('master_id', $userId) + ->whereIn('status', ['in_work', 'awaiting_parts', 'ready', 'done', 'approved', 'diagnosis']) + ->orderBy('opened_at', 'desc') + ->get(); + + return [ + [ + 'key' => 'in_work', + 'label' => 'În lucru', + 'color' => '#f59e0b', + 'items' => $all->where('status', 'in_work')->values(), + ], + [ + 'key' => 'awaiting_parts', + 'label' => 'Așteaptă piese', + 'color' => '#8b5cf6', + 'items' => $all->whereIn('status', ['awaiting_parts'])->values(), + ], + [ + 'key' => 'ready', + 'label' => 'Gata', + 'color' => '#10b981', + 'items' => $all->where('status', 'ready')->values(), + ], + [ + 'key' => 'recent', + 'label' => 'Recente / restul', + 'color' => '#64748b', + 'items' => $all->whereIn('status', ['done', 'approved', 'diagnosis']) + ->take(20) + ->values(), + ], + ]; + } + + public function getCounts(): array + { + $userId = auth()->id(); + return [ + 'active' => $userId + ? WorkOrder::where('master_id', $userId) + ->whereIn('status', ['in_work', 'awaiting_parts', 'ready']) + ->count() + : 0, + 'closed_today' => $userId + ? WorkOrder::where('master_id', $userId) + ->where('status', 'done') + ->whereDate('closed_at', today()) + ->count() + : 0, + ]; + } +} diff --git a/app/Filament/Tenant/Pages/Scanner.php b/app/Filament/Tenant/Pages/Scanner.php new file mode 100644 index 0000000..6123d04 --- /dev/null +++ b/app/Filament/Tenant/Pages/Scanner.php @@ -0,0 +1,79 @@ +` payload (our own QR labels) + * - exact barcode match on parts.barcode + * - exact article match on parts.article + * On match → redirect to Part edit page. + */ +class Scanner extends Page +{ + protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-qr-code'; + + protected static ?string $navigationLabel = 'Scaner'; + + protected static string|\UnitEnum|null $navigationGroup = 'Depozit'; + + protected static ?int $navigationSort = 39; + + protected static ?string $title = 'Scaner cod QR / Bare'; + + protected string $view = 'filament.tenant.pages.scanner'; + + public string $manual = ''; + + #[On('scanner-decoded')] + public function decoded(string $text): void + { + $this->resolveAndRedirect(trim($text)); + } + + public function submitManual(): void + { + if (trim($this->manual) === '') return; + $this->resolveAndRedirect(trim($this->manual)); + } + + protected function resolveAndRedirect(string $code): void + { + $clean = $code; + if (str_starts_with($clean, 'PART:')) { + $clean = substr($clean, 5); + } + + $part = Part::where(function ($q) use ($clean, $code) { + $q->where('article', $clean) + ->orWhere('barcode', $clean) + ->orWhere('barcode', $code); + if (ctype_digit($clean)) $q->orWhere('id', (int) $clean); + }) + ->first(); + + if (! $part) { + Notification::make() + ->title('Cod necunoscut') + ->body('Nu am găsit nicio piesă pentru: ' . $code) + ->warning() + ->send(); + return; + } + + Notification::make() + ->title('Piesă găsită: ' . $part->name) + ->success() + ->send(); + + $this->redirect( + route('filament.tenant.resources.parts.edit', ['record' => $part->id]) + ); + } +} diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php index d4f50e8..dc5c99b 100644 --- a/app/Filament/Tenant/Resources/PartResource.php +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -135,6 +135,26 @@ class PartResource extends Resource ->query(fn ($q) => $q->where('qty', '<=', 0)), ]) ->actions([ + Actions\Action::make('qr') + ->label('QR') + ->icon('heroicon-m-qr-code') + ->color('gray') + ->modalHeading(fn (Part $r) => 'QR pentru ' . $r->name) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Închide') + ->modalContent(function (Part $r) { + $payload = 'PART:' . ($r->article ?: $r->id); + $svg = (new \chillerlan\QRCode\QRCode(new \chillerlan\QRCode\QROptions([ + 'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG, + 'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M, + 'scale' => 8, + 'imageBase64' => false, + 'addQuietzone' => true, + ])))->render($payload); + return view('filament.tenant.part-qr', [ + 'part' => $r, 'svg' => $svg, 'payload' => $payload, + ]); + }), Actions\Action::make('ai_price') ->label('AI: preț recomandat') ->icon('heroicon-m-sparkles') @@ -186,6 +206,17 @@ class PartResource extends Resource Actions\EditAction::make(), Actions\DeleteAction::make(), ]) + ->bulkActions([ + Actions\BulkAction::make('print_labels') + ->label('Tipărește etichete QR') + ->icon('heroicon-m-printer') + ->color('gray') + ->action(function ($records) { + $ids = collect($records)->pluck('id')->implode(','); + return redirect()->away('/parts/labels?ids=' . $ids); + }) + ->deselectRecordsAfterCompletion(), + ]) ->emptyStateHeading('Depozit gol') ->emptyStateDescription('Adaugă piese manual, sau folosește Achiziții ca să le adaugi prin recepție de la furnizor (cu prețuri și stoc auto). Procentaj poate seta automat prețul de vânzare.') ->emptyStateIcon('heroicon-o-cube') diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php index 25af720..8588750 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php @@ -3,9 +3,12 @@ namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers; use App\Models\Tenant\Part; +use App\Models\Tenant\PartReservation; use App\Models\Tenant\WorkOrderPart; +use App\Services\Warehouse\WarehouseService; use Filament\Actions; use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; @@ -82,6 +85,48 @@ class PartsRelationManager extends RelationManager Actions\CreateAction::make(), ]) ->actions([ + Actions\Action::make('issue_now') + ->label('Eliberează') + ->icon('heroicon-m-arrow-up-on-square') + ->color('warning') + ->visible(fn (WorkOrderPart $r) => $r->part_id + && PartReservation::where('work_order_part_id', $r->id) + ->where('status', PartReservation::STATUS_ACTIVE) + ->exists()) + ->requiresConfirmation() + ->modalDescription('Confirmă că mecanicul ia fizic piesa din depozit. Stocul scade acum, fără să aștepți închiderea fișei.') + ->action(function (WorkOrderPart $r) { + $n = app(WarehouseService::class)->issueNow($r); + Notification::make() + ->title("Eliberat: {$n} rezervări consumate") + ->success()->send(); + }), + Actions\Action::make('return_part') + ->label('Restituire') + ->icon('heroicon-m-arrow-uturn-left') + ->color('gray') + ->visible(fn (WorkOrderPart $r) => $r->part_id + && PartReservation::where('work_order_part_id', $r->id) + ->where('status', PartReservation::STATUS_CONSUMED) + ->exists()) + ->schema([ + Forms\Components\TextInput::make('qty') + ->label('Cantitate restituită') + ->numeric() + ->required() + ->minValue(0.001) + ->default(fn (WorkOrderPart $r) => (float) $r->qty), + Forms\Components\Textarea::make('notes')->rows(2)->label('Observații'), + ]) + ->action(function (WorkOrderPart $r, array $data) { + $batch = app(WarehouseService::class)->returnPart( + $r, (float) $data['qty'], $data['notes'] ?? null + ); + Notification::make() + ->title($batch ? 'Piesa returnată în stoc' : 'Nimic de restituit') + ->{$batch ? 'success' : 'warning'}() + ->send(); + }), Actions\EditAction::make(), Actions\DeleteAction::make(), ]); diff --git a/app/Http/Controllers/PartLabelsController.php b/app/Http/Controllers/PartLabelsController.php new file mode 100644 index 0000000..90c108b --- /dev/null +++ b/app/Http/Controllers/PartLabelsController.php @@ -0,0 +1,38 @@ +query('ids', '')))); + if (empty($ids)) abort(400, 'No parts selected.'); + + $parts = Part::whereIn('id', $ids)->orderBy('name')->get(); + + $opts = new QROptions([ + 'outputType' => QRCode::OUTPUT_MARKUP_SVG, + 'eccLevel' => QRCode::ECC_M, + 'scale' => 4, + 'imageBase64' => false, + 'addQuietzone' => true, + ]); + + $labels = $parts->map(function (Part $p) use ($opts) { + $payload = 'PART:' . ($p->article ?: $p->id); + return [ + 'part' => $p, + 'svg' => (new QRCode($opts))->render($payload), + 'payload' => $payload, + ]; + }); + + return view('parts.labels', ['labels' => $labels]); + } +} diff --git a/app/Services/Warehouse/WarehouseService.php b/app/Services/Warehouse/WarehouseService.php index cb96df7..f9c9357 100644 --- a/app/Services/Warehouse/WarehouseService.php +++ b/app/Services/Warehouse/WarehouseService.php @@ -262,6 +262,95 @@ class WarehouseService }); } + /** + * Issue parts NOW for a single WO line — used when the mechanic physically + * takes the part from the shelf before the WO is closed. Same logic as + * consume() but scoped to one work_order_part_id. + */ + public function issueNow(WorkOrderPart $wop): int + { + return DB::transaction(function () use ($wop) { + $active = PartReservation::with(['batch', 'part']) + ->where('work_order_part_id', $wop->id) + ->where('status', PartReservation::STATUS_ACTIVE) + ->lockForUpdate() + ->get(); + + foreach ($active as $r) { + $batch = $r->batch; + if (! $batch) continue; + $take = min((float) $r->qty, (float) $batch->qty_remaining); + if ($take <= 0) continue; + + $batch->qty_remaining = (float) $batch->qty_remaining - $take; + $batch->save(); + + $this->logEvent( + $r->part, $batch, $batch->warehouse, + 'issue', -$take, (float) $batch->buy_price, + $wop->workOrder, "WO part #{$wop->id} (issue now)" + ); + + $r->status = PartReservation::STATUS_CONSUMED; + $r->consumed_at = now(); + $r->save(); + + if ($r->part) { + $r->part->qty_reserved = max(0.0, (float) $r->part->qty_reserved - (float) $r->qty); + $r->part->saveQuietly(); + } + } + + if ($wop->part) $this->syncPartCachedQty($wop->part); + return $active->count(); + }); + } + + /** + * Return parts physically taken back to stock. Creates a NEW batch with + * the same buy_price as the original (FIFO-correct re-entry) and writes + * a `return` event referencing the WO part. + */ + public function returnPart(WorkOrderPart $wop, ?float $qty = null, ?string $notes = null): ?PartBatch + { + if (! $wop->part_id) return null; + + return DB::transaction(function () use ($wop, $qty, $notes) { + $consumed = PartReservation::where('work_order_part_id', $wop->id) + ->where('status', PartReservation::STATUS_CONSUMED) + ->orderBy('consumed_at', 'desc') + ->get(); + + $totalConsumed = (float) $consumed->sum('qty'); + $qtyReturn = $qty !== null ? min((float) $qty, $totalConsumed) : $totalConsumed; + if ($qtyReturn <= 0) return null; + + $unitCost = (float) ($consumed->first()?->batch?->buy_price ?? $wop->buy_price); + $warehouse = $consumed->first()?->batch?->warehouse + ?? $this->defaultWarehouse($wop->company_id); + + $batch = PartBatch::create([ + 'company_id' => $wop->company_id, + 'part_id' => $wop->part_id, + 'warehouse_id' => $warehouse->id, + 'qty_in' => $qtyReturn, + 'qty_remaining' => $qtyReturn, + 'buy_price' => $unitCost, + 'received_at' => now(), + 'notes' => $notes ?? "Retur de la WO part #{$wop->id}", + ]); + + $this->logEvent( + $wop->part, $batch, $warehouse, + 'return', $qtyReturn, $unitCost, + $wop->workOrder, $notes ?? "Retur WO #{$wop->workOrder?->number}" + ); + + $this->syncPartCachedQty($wop->part); + return $batch; + }); + } + /** * Move stock between warehouses (FIFO from source). Creates one transfer_out * + one transfer_in batch in the destination warehouse. diff --git a/resources/views/filament/tenant/pages/mechanic-board.blade.php b/resources/views/filament/tenant/pages/mechanic-board.blade.php new file mode 100644 index 0000000..5628b0d --- /dev/null +++ b/resources/views/filament/tenant/pages/mechanic-board.blade.php @@ -0,0 +1,83 @@ + + @php + $cols = $this->getColumns(); + $counts = $this->getCounts(); + @endphp + + + +
+
+
Active acum
+
{{ $counts['active'] }}
+
+
+
Închise azi
+
{{ $counts['closed_today'] }}
+
+
+ +
+ @foreach ($cols as $col) + + @endforeach +
+
diff --git a/resources/views/filament/tenant/pages/scanner.blade.php b/resources/views/filament/tenant/pages/scanner.blade.php new file mode 100644 index 0000000..178e1ec --- /dev/null +++ b/resources/views/filament/tenant/pages/scanner.blade.php @@ -0,0 +1,111 @@ + + + +
+
+
+
+ Apasă „Pornește" pentru a deschide camera. +
+
+
+ +
+ + +
+ +
+ Ultimul cod citit: +
+ +
+
+ + +
+
+
+ + + +
diff --git a/resources/views/filament/tenant/part-qr.blade.php b/resources/views/filament/tenant/part-qr.blade.php new file mode 100644 index 0000000..abcaef4 --- /dev/null +++ b/resources/views/filament/tenant/part-qr.blade.php @@ -0,0 +1,12 @@ +
+
+
{!! $svg !!}
+
+
+
{{ $part->name }}
+ @if ($part->article) +
{{ $part->article }}
+ @endif +
Payload: {{ $payload }}
+
+
diff --git a/resources/views/parts/labels.blade.php b/resources/views/parts/labels.blade.php new file mode 100644 index 0000000..7409229 --- /dev/null +++ b/resources/views/parts/labels.blade.php @@ -0,0 +1,65 @@ + + + + + Etichete piese + + + +
+ {{ $labels->count() }} etichete + +
+ +
+ @foreach ($labels as $lbl) +
+
{!! $lbl['svg'] !!}
+
+
{{ $lbl['part']->name }}
+ @if ($lbl['part']->article) +
{{ $lbl['part']->article }}
+ @endif + @if ($lbl['part']->brand) +
{{ $lbl['part']->brand }}
+ @endif +
+
+ @endforeach +
+ + diff --git a/routes/web.php b/routes/web.php index 92feba7..9fdf494 100644 --- a/routes/web.php +++ b/routes/web.php @@ -57,6 +57,12 @@ Route::get('/login', function (Request $request) { return redirect($tenant ? '/app/login' : '/admin/login'); })->name('login'); +// ─── Print sheets (auth required, tenant-scoped) ─────────────────── +Route::middleware(['web', 'auth'])->group(function () { + Route::get('/parts/labels', [\App\Http\Controllers\PartLabelsController::class, 'sheet']) + ->name('parts.labels'); +}); + // ─── Telegram webhook (per-tenant, on central domain) ────────────── Route::post('/telegram/webhook/{slug}', [\App\Http\Controllers\TelegramWebhookController::class, 'handle']) ->where('slug', '[a-z0-9\-]+') diff --git a/tests/Feature/ScannerLookupTest.php b/tests/Feature/ScannerLookupTest.php new file mode 100644 index 0000000..987ebcd --- /dev/null +++ b/tests/Feature/ScannerLookupTest.php @@ -0,0 +1,74 @@ +makePart('FA-001'); + + // Simulate the controller-level lookup logic (Scanner::resolveAndRedirect). + $code = 'PART:FA-001'; + $clean = str_starts_with($code, 'PART:') ? substr($code, 5) : $code; + + $found = Part::where(function ($q) use ($clean) { + $q->where('article', $clean)->orWhere('barcode', $clean); + if (ctype_digit($clean)) $q->orWhere('id', (int) $clean); + })->first(); + + $this->assertNotNull($found); + $this->assertEquals($part->id, $found->id); + } + + public function test_scanner_finds_part_by_raw_barcode(): void + { + [$company, $part] = $this->makePart('FA-002', '4607177921365'); + + $code = '4607177921365'; + $found = Part::where('barcode', $code)->orWhere('article', $code)->first(); + $this->assertNotNull($found); + $this->assertEquals($part->id, $found->id); + } + + public function test_scanner_misses_for_unknown_code(): void + { + $this->makePart('FA-003'); + + $found = Part::where('article', 'GHOST-999')->orWhere('barcode', 'GHOST-999')->first(); + $this->assertNull($found); + } + + private function makePart(string $article, ?string $barcode = null): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'sc-' . uniqid(), + 'name' => 'Scanner Service', + 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + + $part = Part::create([ + 'name' => 'Filtru ' . $article, + 'article' => $article, + 'barcode' => $barcode, + 'unit' => 'buc', + 'qty' => 0, + 'buy_price' => 0, 'sell_price' => 0, + 'is_active' => true, + ]); + + return [$company, $part]; + } +} diff --git a/tests/Feature/WarehouseIssueReturnTest.php b/tests/Feature/WarehouseIssueReturnTest.php new file mode 100644 index 0000000..7052596 --- /dev/null +++ b/tests/Feature/WarehouseIssueReturnTest.php @@ -0,0 +1,153 @@ +svc = app(WarehouseService::class); + } + + public function test_issue_now_consumes_immediately_without_wo_done(): void + { + $ctx = $this->ctx(); + $this->svc->receive($ctx['part'], 10, 25.0); + + $wop = WorkOrderPart::create([ + 'work_order_id' => $ctx['wo']->id, + 'part_id' => $ctx['part']->id, + 'name' => $ctx['part']->name, + 'qty' => 3, 'unit' => 'buc', + 'buy_price' => 25, 'sell_price' => 40, + ]); + + $ctx['part']->refresh(); + $this->assertEquals(3.0, (float) $ctx['part']->qty_reserved); + + $n = $this->svc->issueNow($wop); + $this->assertGreaterThan(0, $n); + + $ctx['part']->refresh(); + $this->assertEquals(7.0, (float) $ctx['part']->qty, 'on-hand decreased without closing WO'); + $this->assertEquals(0.0, (float) $ctx['part']->qty_reserved, 'reservation no longer counted'); + + $r = PartReservation::where('work_order_part_id', $wop->id)->first(); + $this->assertEquals('consumed', $r->status); + } + + public function test_return_part_creates_positive_batch_and_event(): void + { + $ctx = $this->ctx(); + $this->svc->receive($ctx['part'], 10, 30.0); + + $wop = WorkOrderPart::create([ + 'work_order_id' => $ctx['wo']->id, + 'part_id' => $ctx['part']->id, + 'name' => $ctx['part']->name, + 'qty' => 4, 'unit' => 'buc', + 'buy_price' => 30, 'sell_price' => 50, + ]); + $this->svc->issueNow($wop); + + $ctx['part']->refresh(); + $this->assertEquals(6.0, (float) $ctx['part']->qty); + + $batch = $this->svc->returnPart($wop, 2); + $this->assertNotNull($batch); + $this->assertEquals(2.0, (float) $batch->qty_in); + $this->assertEquals(30.0, (float) $batch->buy_price); + + $ctx['part']->refresh(); + $this->assertEquals(8.0, (float) $ctx['part']->qty, 'returned 2 restored to on-hand'); + + $this->assertEquals(1, WarehouseEvent::where('part_id', $ctx['part']->id) + ->where('type', 'return') + ->count()); + } + + public function test_return_more_than_consumed_is_capped(): void + { + $ctx = $this->ctx(); + $this->svc->receive($ctx['part'], 10, 10.0); + + $wop = WorkOrderPart::create([ + 'work_order_id' => $ctx['wo']->id, + 'part_id' => $ctx['part']->id, + 'name' => $ctx['part']->name, + 'qty' => 3, 'unit' => 'buc', + 'buy_price' => 10, 'sell_price' => 15, + ]); + $this->svc->issueNow($wop); + + $batch = $this->svc->returnPart($wop, 99); + $this->assertEquals(3.0, (float) $batch->qty_in, 'capped at consumed total'); + } + + private function ctx(): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, + 'slug' => 'ir-' . uniqid(), + 'name' => 'IR Service', + 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + + $wh = Warehouse::create([ + 'code' => 'MAIN', 'name' => 'Depozit', + 'is_default' => true, 'is_active' => true, + ]); + $company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly(); + + $part = Part::create([ + 'name' => 'Filtru aer', + 'unit' => 'buc', + 'qty' => 0, + 'buy_price' => 0, 'sell_price' => 0, + 'is_active' => true, + ]); + + $client = Client::create([ + 'name' => 'WO Client', + 'phone' => '+37399' . random_int(100000, 999999), + 'type' => 'individual', 'status' => 'active', + ]); + $vehicle = Vehicle::create([ + 'client_id' => $client->id, + 'make' => 'A', 'model' => 'B', + 'plate' => 'IR' . random_int(100, 999), + ]); + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($company->id), + 'client_id' => $client->id, + 'vehicle_id' => $vehicle->id, + 'opened_at' => now(), + 'status' => 'in_work', + ]); + + return compact('company', 'wh', 'part', 'client', 'vehicle', 'wo'); + } +}