Stage 7+14 — Mechanic Board + Scan Center
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:<article|id> - 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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScannerLookupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_scanner_finds_part_by_part_prefix_payload(): void
|
||||
{
|
||||
[$company, $part] = $this->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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\PartBatch;
|
||||
use App\Models\Tenant\PartReservation;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\Warehouse;
|
||||
use App\Models\Tenant\WarehouseEvent;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Models\Tenant\WorkOrderPart;
|
||||
use App\Services\Warehouse\WarehouseService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WarehouseIssueReturnTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private WarehouseService $svc;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user