diff --git a/Modules/MaterialLogistics/app/Http/Controllers/PurchaseOrderController.php b/Modules/MaterialLogistics/app/Http/Controllers/PurchaseOrderController.php index 2473272..071c115 100644 --- a/Modules/MaterialLogistics/app/Http/Controllers/PurchaseOrderController.php +++ b/Modules/MaterialLogistics/app/Http/Controllers/PurchaseOrderController.php @@ -334,14 +334,37 @@ class PurchaseOrderController extends Controller return back()->with('error', 'This purchase order is already paid.'); } - $validated = $request->validate([ - 'receipt' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', + $hasFileInfo = extension_loaded('fileinfo'); + + $rules = [ + 'receipt' => 'required|file|max:10240', 'warehouse_ulid' => 'nullable|string', - ]); + ]; + + if ($hasFileInfo) { + $rules['receipt'] .= '|mimes:pdf,jpg,jpeg,png'; + } + + $validated = $request->validate($rules); + + $file = $request->file('receipt'); + + if (!$hasFileInfo) { + $extension = strtolower($file->getClientOriginalExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION)); + if (!in_array($extension, ['pdf', 'jpg', 'jpeg', 'png'])) { + return back()->withErrors(['receipt' => 'The receipt must be a file of type: pdf, jpg, jpeg, png.']); + } + } // Store receipt file - $file = $request->file('receipt'); - $path = $file->store("po-receipts/{$purchaseOrder->id}", 'public'); + if ($hasFileInfo) { + $path = $file->store("po-receipts/{$purchaseOrder->id}", 'public'); + } else { + $extension = strtolower($file->getClientOriginalExtension() ?: pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION)); + $fileName = \Illuminate\Support\Str::random(40) . '.' . $extension; + $path = "po-receipts/{$purchaseOrder->id}/" . $fileName; + \Illuminate\Support\Facades\Storage::disk('public')->put($path, fopen($file->getRealPath(), 'r')); + } DB::transaction(function () use ($purchaseOrder, $path, $file, $request) { $purchaseOrder->update([ diff --git a/docs/PLAN.md b/docs/PLAN.md index 72ee9dc..3b11d94 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,60 +1,29 @@ -# PLAN - Dynamic Site Execution Dashboard +# PLAN - Fix Class "finfo" Not Found on File Uploads -Make the main dashboard dynamic by connecting its components to database records for Projects, Tasks, Daily Reports, Delays, Workforce, and Equipment. - -## User Decisions Incorporated -- **Default view**: Global aggregated view of all projects. -- **Weather API**: Open-Meteo integration based on selected project location. -- **Access control**: Show all projects in dropdown for all users with dashboard access. +Resolve the file upload crash caused by missing PHP `fileinfo` extension on the hosting server. ## Proposed Changes -### Dashboard Backend (Laravel) +### Material Logistics Module (Backend) -#### [MODIFY] [DashboardController.php](file:///c:/laragon/www/gsb-cons/app/Http/Controllers/DashboardController.php) -- Fetch all active/available projects in the system. -- Retrieve the selected project via the `project` query parameter. -- **Global View (no project selected)**: - - Aggregate resources (workforce count and equipment count) across all active projects. - - Compile and sort blockers (delays and daily issues) across all projects. - - Fetch recent activities chronologically across all projects. - - Weather: Fall back to central office/depot location or a nice aggregate message. -- **Project View (project selected)**: - - Filter resources for that project from the latest daily reports. - - Filter blockers for that project from `TaskDelay` and `DailyReportIssue`. - - Filter activities for that project from `TaskActivity` and daily reports. - - Weather: Dynamic query to Open-Meteo using the project's location. -- **Weather API Integration**: - - Implement dynamic weather lookup using a geocoding approximation or Open-Meteo API. - - Cache results for 1 hour. - ---- - -### Dashboard Frontend (React) - -#### [MODIFY] [Dashboard.tsx](file:///c:/laragon/www/gsb-cons/resources/js/Pages/Dashboard.tsx) -- Integrate a Project Selector dropdown that supports a "Global Overview" option. -- Reload page data using Inertia with the selected project filter. -- Pass fetched database parameters to each sub-widget. - -#### [MODIFY] [WeatherWidget.tsx](file:///c:/laragon/www/gsb-cons/resources/js/Components/Dashboard/WeatherWidget.tsx) -#### [MODIFY] [BlockerList.tsx](file:///c:/laragon/www/gsb-cons/resources/js/Components/Dashboard/BlockerList.tsx) -#### [MODIFY] [ActivityFeed.tsx](file:///c:/laragon/www/gsb-cons/resources/js/Components/Dashboard/ActivityFeed.tsx) -#### [MODIFY] [ResourceSummary.tsx](file:///c:/laragon/www/gsb-cons/resources/js/Components/Dashboard/ResourceSummary.tsx) -- Update props interfaces and render dynamic data instead of static fallback values. +#### [MODIFY] [PurchaseOrderController.php](file:///c:/laragon/www/gsb-cons/Modules/MaterialLogistics/app/Http/Controllers/PurchaseOrderController.php) +- Implement defensive fallback checks in `markAsPaid` method for the presence of the `fileinfo` PHP extension. +- If `fileinfo` is missing: + - Validate the file extension manually using the uploaded file's original name. + - Store the file by writing a stream using `Storage::disk('public')->put(..., fopen(..., 'r'))` to bypass Symfony's MIME guesser. +- If `fileinfo` is present: + - Proceed with standard Laravel validation (`mimes`) and storage (`store()`). --- ## Verification Plan ### Automated Tests -- Run validation checklist: +- Write a Feature Test to mock/validate file uploads with and without `fileinfo` support. +- Run tests: ```bash - python .agent/scripts/checklist.py . + C:\laragon\bin\php\php-8.3.30-Win32-vs16-x64\php.exe artisan test ``` -- Run PHPUnit tests for Dashboard authorization and query filters. ### Manual Verification -1. Log in and switch between "Global Overview" and specific projects. -2. Verify all widgets refresh to show corresponding aggregated or project-specific data. -3. Test that the weather widget displays real-time weather when a project location is selected. +1. Upload a PO receipt and verify it succeeds on the server without throwing the `finfo` class missing error. diff --git a/tests/Feature/PurchaseOrderReceiptUploadTest.php b/tests/Feature/PurchaseOrderReceiptUploadTest.php new file mode 100644 index 0000000..f9ade98 --- /dev/null +++ b/tests/Feature/PurchaseOrderReceiptUploadTest.php @@ -0,0 +1,143 @@ +user = User::factory()->create([ + 'status' => 'active', + 'user_type' => 'admin', + ]); + + // Create required permission and assign to user + \Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'inventory.access']); + $this->user->givePermissionTo('inventory.access'); + + // Create target warehouse + $warehouse = Warehouse::create([ + 'name' => 'Main Storage Warehouse', + 'code' => 'WH-TEST-01', + 'address' => 'Test Location', + 'type' => 'main', + 'status' => 'active', + ]); + + // Create approved PO + $this->purchaseOrder = PurchaseOrder::create([ + 'document_number' => 'PO-2026-0001', + 'supplier' => 'Acme Supplies', + 'status' => 'approved', + 'payment_status' => 'unpaid', + 'target_warehouse_id' => $warehouse->id, + 'requested_by' => $this->user->id, + ]); + + // Reset fileinfo mock global + unset($GLOBALS['mock_fileinfo_disabled']); + } + + protected function tearDown(): void + { + unset($GLOBALS['mock_fileinfo_disabled']); + parent::tearDown(); + } + + public function test_can_upload_receipt_and_mark_po_as_paid_with_fileinfo(): void + { + Storage::fake('public'); + + $file = UploadedFile::fake()->create('receipt.pdf', 500, 'application/pdf'); + + $response = $this->actingAs($this->user) + ->from(route('purchase-orders.show', $this->purchaseOrder)) + ->post(route('purchase-orders.pay', $this->purchaseOrder), [ + 'receipt' => $file, + ]); + + $response->assertRedirect(route('purchase-orders.show', $this->purchaseOrder)); + $response->assertSessionHas('success'); + + $this->purchaseOrder->refresh(); + $this->assertEquals('paid', $this->purchaseOrder->payment_status->value); + $this->assertNotNull($this->purchaseOrder->receipt_path); + $this->assertEquals('receipt.pdf', $this->purchaseOrder->receipt_original_name); + + // Assert file exists on storage + Storage::disk('public')->assertExists($this->purchaseOrder->receipt_path); + } + + public function test_can_upload_receipt_and_mark_po_as_paid_without_fileinfo(): void + { + // Enable fileinfo missing mock + $GLOBALS['mock_fileinfo_disabled'] = true; + + Storage::fake('public'); + + $file = UploadedFile::fake()->create('receipt.pdf', 500, 'application/pdf'); + + $response = $this->actingAs($this->user) + ->from(route('purchase-orders.show', $this->purchaseOrder)) + ->post(route('purchase-orders.pay', $this->purchaseOrder), [ + 'receipt' => $file, + ]); + + $response->assertRedirect(route('purchase-orders.show', $this->purchaseOrder)); + $response->assertSessionHas('success'); + + $this->purchaseOrder->refresh(); + $this->assertEquals('paid', $this->purchaseOrder->payment_status->value); + $this->assertNotNull($this->purchaseOrder->receipt_path); + $this->assertEquals('receipt.pdf', $this->purchaseOrder->receipt_original_name); + + // Assert file exists on storage + Storage::disk('public')->assertExists($this->purchaseOrder->receipt_path); + } + + public function test_rejects_invalid_mime_type_extension_without_fileinfo(): void + { + $GLOBALS['mock_fileinfo_disabled'] = true; + + $file = UploadedFile::fake()->create('receipt.txt', 10, 'text/plain'); + + $response = $this->actingAs($this->user) + ->from(route('purchase-orders.show', $this->purchaseOrder)) + ->post(route('purchase-orders.pay', $this->purchaseOrder), [ + 'receipt' => $file, + ]); + + $response->assertSessionHasErrors('receipt'); + $this->assertEquals('unpaid', $this->purchaseOrder->refresh()->payment_status->value); + } + } +}