From dac00291a7f5bd021373e4f2347556f2cc80be50 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 21 May 2026 15:43:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=A6=E4=BD=9C=E8=AA=BF=E6=92=A5?= =?UTF-8?q?=E5=96=AE=E8=88=87=E6=A9=9F=E5=8F=B0=E8=A3=9C=E8=B2=A8=E5=96=AE?= =?UTF-8?q?=E7=9A=84=20A4=20=E6=A5=B5=E7=B0=A1=E5=A5=A2=E8=8F=AF=E9=A2=A8?= =?UTF-8?q?=E5=88=97=E5=8D=B0=E5=8A=9F=E8=83=BD=E8=88=87=E5=A4=9A=E7=A7=9F?= =?UTF-8?q?=E6=88=B6=E9=9B=99=E9=87=8D=E9=98=B2=E8=AD=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Admin/WarehouseController.php | 43 +++ .../slideover-replenishment-details.blade.php | 60 ++-- .../warehouses/print/replenishment.blade.php | 264 +++++++++++++++++ .../admin/warehouses/print/transfer.blade.php | 268 ++++++++++++++++++ .../admin/warehouses/transfers.blade.php | 31 +- routes/web.php | 2 + tests/Feature/Admin/PrintStockOrdersTest.php | 174 ++++++++++++ 7 files changed, 816 insertions(+), 26 deletions(-) create mode 100644 resources/views/admin/warehouses/print/replenishment.blade.php create mode 100644 resources/views/admin/warehouses/print/transfer.blade.php create mode 100644 tests/Feature/Admin/PrintStockOrdersTest.php diff --git a/app/Http/Controllers/Admin/WarehouseController.php b/app/Http/Controllers/Admin/WarehouseController.php index 50aa134..944ca61 100644 --- a/app/Http/Controllers/Admin/WarehouseController.php +++ b/app/Http/Controllers/Admin/WarehouseController.php @@ -1407,4 +1407,47 @@ class WarehouseController extends Controller 'items' => $items ]); } + + /** + * 列印調撥單 + */ + public function printTransfer($id) + { + $order = TransferOrder::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator']) + ->findOrFail($id); + + // 雙重防護:驗證租戶資料隔離安全 + $user = auth()->user(); + if ($user && $user->company_id && $order->company_id !== $user->company_id) { + abort(404); + } + + $items = \App\Models\Warehouse\TransferOrderItem::with(['product' => fn($q) => $q->with('translations')]) + ->where('transfer_order_id', $id) + ->get(); + + return view('admin.warehouses.print.transfer', compact('order', 'items')); + } + + /** + * 列印機台補貨單 + */ + public function printReplenishment($id) + { + $order = ReplenishmentOrder::with(['machine', 'warehouse', 'creator', 'assignee']) + ->findOrFail($id); + + // 雙重防護:驗證租戶資料隔離安全 + $user = auth()->user(); + if ($user && $user->company_id && $order->company_id !== $user->company_id) { + abort(404); + } + + $items = ReplenishmentOrderItem::with(['product' => fn($q) => $q->with('translations')]) + ->where('replenishment_order_id', $id) + ->get(); + + return view('admin.warehouses.print.replenishment', compact('order', 'items')); + } } + diff --git a/resources/views/admin/warehouses/partials/slideover-replenishment-details.blade.php b/resources/views/admin/warehouses/partials/slideover-replenishment-details.blade.php index 251b9b8..b94a259 100644 --- a/resources/views/admin/warehouses/partials/slideover-replenishment-details.blade.php +++ b/resources/views/admin/warehouses/partials/slideover-replenishment-details.blade.php @@ -15,9 +15,17 @@

- +
+ + +
{{-- Content --}} @@ -68,23 +76,35 @@ {{-- Action Buttons --}} - +
+ {{-- 列印按鈕永遠顯示 --}} + + + {{-- 其他需要依狀態顯示的動作 --}} + +
{{-- Items List --}}
diff --git a/resources/views/admin/warehouses/print/replenishment.blade.php b/resources/views/admin/warehouses/print/replenishment.blade.php new file mode 100644 index 0000000..cbba7f7 --- /dev/null +++ b/resources/views/admin/warehouses/print/replenishment.blade.php @@ -0,0 +1,264 @@ + + + + + + {{ __('Replenishment Order') }} - {{ $order->order_no }} + + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + + +
+ + +
+
+ + {{ __('Ready to print') }} +
+
+ + +
+
+ + + + + +
+ +
+

{{ __('Source Warehouse') }}

+

{{ $order->warehouse?->name ?? '-' }}

+
+ +
+

{{ __('Target Machine') }}

+

{{ $order->machine?->name ?? '-' }} ({{ $order->machine?->serial_no }})

+
+ +
+

{{ __('Created By') }}

+

{{ $order->creator?->name ?? __('System') }}

+
+ +
+

{{ __('Assigned Personnel') }}

+

{{ $order->assignee?->name ?? __('Unassigned') }}

+
+ +
+

{{ __('Created At') }}

+

{{ $order->created_at->timezone('Asia/Taipei')->format('Y-m-d H:i:s') }}

+
+ +
+

{{ __('Print Time') }}

+

{{ now()->timezone('Asia/Taipei')->format('Y-m-d H:i:s') }}

+
+ + @if($order->note) +
+

{{ __('Note') }}

+

{{ $order->note }}

+
+ @endif +
+ + +
+

{{ __('Replenishment Details') }} ({{ $items->count() }} {{ __('Items') }})

+ + + + + + + + + + + + + + + + + @foreach($items as $index => $item) + + + + + + + + + + + + + @endforeach + +
+ + {{ $item->slot_no }} + + + + +

{{ $item->product?->localized_name ?? __('Unknown') }}

+

ID: {{ $item->product_id }}

+
+ {{ $item->product?->barcode ?? '-' }} + + {{ $item->current_stock }} / {{ $item->max_stock }} + +

x{{ $item->quantity }}

+
+
+
+
+ + +
+ + + +
+

{{ __('Print Notice') }}

+

{{ __('Please make sure to enable "Background graphics" in your browser print settings to print product images and styled components correctly.') }}

+
+
+
+ + +
+
+
+

{{ __('Prepared By') }}

+ +
+
+

{{ __('Warehouse Stock Out') }}

+ +
+
+

{{ __('Courier/Replenisher') }}

+ +
+
+

{{ __('Machine Inbound Confirmation') }}

+ +
+
+ +
+ Star Cloud © 2026. All rights reserved. +
+
+ + + + + diff --git a/resources/views/admin/warehouses/print/transfer.blade.php b/resources/views/admin/warehouses/print/transfer.blade.php new file mode 100644 index 0000000..317b3c7 --- /dev/null +++ b/resources/views/admin/warehouses/print/transfer.blade.php @@ -0,0 +1,268 @@ + + + + + + {{ __('Transfer Order') }} - {{ $order->order_no }} + + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + + +
+ + +
+
+ + {{ __('Ready to print') }} +
+
+ + +
+
+ + + + + +
+ +
+ @if($order->type === \App\Models\Warehouse\TransferOrder::TYPE_W2W) +

{{ __('Source Warehouse') }}

+

{{ $order->fromWarehouse?->name ?? '-' }}

+ @else +

{{ __('Source Machine') }}

+

+ {{ $order->fromMachine?->name ?? '-' }} + ({{ $order->fromMachine?->serial_no }}) +

+ @endif +
+ +
+

{{ __('Target Warehouse') }}

+

{{ $order->toWarehouse?->name ?? '-' }}

+
+ +
+

{{ __('Created By') }}

+

{{ $order->creator?->name ?? __('System') }}

+
+ +
+

{{ __('Transfer Type') }}

+

+ {{ $order->type === \App\Models\Warehouse\TransferOrder::TYPE_W2W ? __('Warehouse to Warehouse') : __('Machine to Warehouse') }} +

+
+ +
+

{{ __('Created At') }}

+

{{ $order->created_at->timezone('Asia/Taipei')->format('Y-m-d H:i:s') }}

+
+ +
+

{{ __('Print Time') }}

+

{{ now()->timezone('Asia/Taipei')->format('Y-m-d H:i:s') }}

+
+ + @if($order->note) +
+

{{ __('Note') }}

+

{{ $order->note }}

+
+ @endif +
+ + +
+

{{ __('Transfer Details') }} ({{ $items->count() }} {{ __('Items') }})

+ + + + + + + + + + + + + + + + @foreach($items as $index => $item) + + + + + + + + + + + + @endforeach + +
+ {{ $index + 1 }} + + + +

{{ $item->product?->localized_name ?? __('Unknown') }}

+

ID: {{ $item->product_id }}

+
+ {{ $item->product?->barcode ?? '-' }} + +

x{{ $item->quantity }}

+
+
+
+
+ + +
+ + + +
+

{{ __('Print Notice') }}

+

{{ __('Please make sure to enable "Background graphics" in your browser print settings to print product images and styled components correctly.') }}

+
+
+
+ + +
+
+
+

{{ __('Prepared By') }}

+ +
+
+

{{ __('Transfer Out Confirmation') }}

+ +
+
+

{{ __('Courier/Dispatcher') }}

+ +
+
+

{{ __('Transfer In Confirmation') }}

+ +
+
+ +
+ Star Cloud © 2026. All rights reserved. +
+
+ + + + + diff --git a/resources/views/admin/warehouses/transfers.blade.php b/resources/views/admin/warehouses/transfers.blade.php index 1df56b5..e31ffd1 100644 --- a/resources/views/admin/warehouses/transfers.blade.php +++ b/resources/views/admin/warehouses/transfers.blade.php @@ -707,12 +707,20 @@
- +
+ + +
@@ -792,6 +800,17 @@ + +
+ +
+
diff --git a/routes/web.php b/routes/web.php index a2cc55a..f7efbeb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -99,6 +99,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix Route::patch('/transfers/{transferOrder}/confirm', [App\Http\Controllers\Admin\WarehouseController::class, 'confirmTransfer'])->name('transfers.confirm'); Route::delete('/transfers/{transferOrder}', [App\Http\Controllers\Admin\WarehouseController::class, 'destroyTransfer'])->name('transfers.destroy'); Route::get('/transfers/{id}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'transferOrderDetails'])->name('transfers.details'); + Route::get('/transfers/{id}/print', [App\Http\Controllers\Admin\WarehouseController::class, 'printTransfer'])->name('transfers.print'); // 模組 4:機台庫存總覽 (Force Route Refresh) Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory'); @@ -114,6 +115,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix Route::patch('/replenishments/{replenishmentOrder}/assign', [App\Http\Controllers\Admin\WarehouseController::class, 'assignReplenishment'])->name('replenishments.assign'); Route::get('/replenishments/machine-slots/{machine}', [App\Http\Controllers\Admin\WarehouseController::class, 'getMachineSlotsForReplenishment'])->name('replenishments.machine-slots'); Route::get('/replenishments/{order}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'replenishmentOrderDetails'])->name('replenishments.details'); + Route::get('/replenishments/{id}/print', [App\Http\Controllers\Admin\WarehouseController::class, 'printReplenishment'])->name('replenishments.print'); // AJAX 庫存查詢 Route::get('/ajax/stock', [App\Http\Controllers\Admin\WarehouseController::class, 'getStockAjax'])->name('ajax.stock'); diff --git a/tests/Feature/Admin/PrintStockOrdersTest.php b/tests/Feature/Admin/PrintStockOrdersTest.php new file mode 100644 index 0000000..971503f --- /dev/null +++ b/tests/Feature/Admin/PrintStockOrdersTest.php @@ -0,0 +1,174 @@ +companyA = Company::create(['name' => 'Company A', 'code' => 'COA']); + $this->companyB = Company::create(['name' => 'Company B', 'code' => 'COB']); + + // 2. 建立使用者 + $this->userA = User::factory()->create([ + 'company_id' => $this->companyA->id, + 'name' => 'User A', + 'email' => 'userA@star-cloud.com' + ]); + $this->userB = User::factory()->create([ + 'company_id' => $this->companyB->id, + 'name' => 'User B', + 'email' => 'userB@star-cloud.com' + ]); + + // 3. 建立倉庫與機台(包含 serial_no 避免 Integrity constraint violation 錯誤) + $this->warehouseA = Warehouse::create([ + 'company_id' => $this->companyA->id, + 'name' => 'Warehouse A', + 'type' => 'main', + 'is_active' => true + ]); + $this->warehouseB = Warehouse::create([ + 'company_id' => $this->companyB->id, + 'name' => 'Warehouse B', + 'type' => 'main', + 'is_active' => true + ]); + + $this->machineA = Machine::create([ + 'company_id' => $this->companyA->id, + 'name' => 'Machine A', + 'serial_no' => 'SN-MAA-01' + ]); + $this->machineB = Machine::create([ + 'company_id' => $this->companyB->id, + 'name' => 'Machine B', + 'serial_no' => 'SN-MBB-02' + ]); + } + + /** + * 測試租戶可以成功列印自己公司的庫存調撥單 + */ + public function test_tenant_can_print_own_transfer_order() + { + $this->actingAs($this->userA); + + // 建立調撥單 + $transfer = TransferOrder::create([ + 'company_id' => $this->companyA->id, + 'order_no' => 'TF-202605210001', + 'type' => 'warehouse_to_warehouse', + 'from_warehouse_id' => $this->warehouseA->id, + 'to_warehouse_id' => $this->warehouseA->id, // 本地測試可以使用同一個以簡化 + 'status' => 'draft', + 'created_by' => $this->userA->id + ]); + + $response = $this->get("/admin/warehouses/transfers/{$transfer->id}/print"); + + $response->assertStatus(200); + $response->assertSee('TF-202605210001'); + $response->assertSee('Star Cloud System'); + $response->assertSee('Prepared By'); + $response->assertSee('Transfer Out Confirmation'); + } + + /** + * 測試租戶可以成功列印自己公司的機台補貨單 + */ + public function test_tenant_can_print_own_replenishment_order() + { + $this->actingAs($this->userA); + + // 建立補貨單 + $replenishment = ReplenishmentOrder::create([ + 'company_id' => $this->companyA->id, + 'order_no' => 'RP-202605210002', + 'warehouse_id' => $this->warehouseA->id, + 'machine_id' => $this->machineA->id, + 'status' => 'pending', + 'created_by' => $this->userA->id + ]); + + $response = $this->get("/admin/warehouses/replenishments/{$replenishment->id}/print"); + + $response->assertStatus(200); + $response->assertSee('RP-202605210002'); + $response->assertSee('Star Cloud System'); + $response->assertSee('Prepared By'); + $response->assertSee('Warehouse Stock Out'); + } + + /** + * 測試租戶嘗試越權存取(列印)其他租戶的調撥單會被阻斷(返回 404) + */ + public function test_tenant_cannot_print_other_tenant_transfer_order() + { + // 先以 User B 建立調撥單 + $this->actingAs($this->userB); + $transferB = TransferOrder::create([ + 'company_id' => $this->companyB->id, + 'order_no' => 'TF-B-9999', + 'type' => 'warehouse_to_warehouse', + 'from_warehouse_id' => $this->warehouseB->id, + 'to_warehouse_id' => $this->warehouseB->id, + 'status' => 'draft', + 'created_by' => $this->userB->id + ]); + + // 切換為 User A 請求列印 User B 的調撥單 + $this->actingAs($this->userA); + $response = $this->get("/admin/warehouses/transfers/{$transferB->id}/print"); + + // 由於 TenantScoped Trait 自動隔離,會直接拋出 ModelNotFoundException 進而由 Laravel 轉換為 404 + $response->assertStatus(404); + } + + /** + * 測試租戶嘗試越權存取(列印)其他租戶的補貨單會被阻斷(返回 404) + */ + public function test_tenant_cannot_print_other_tenant_replenishment_order() + { + // 先以 User B 建立補貨單 + $this->actingAs($this->userB); + $replenishmentB = ReplenishmentOrder::create([ + 'company_id' => $this->companyB->id, + 'order_no' => 'RP-B-9999', + 'warehouse_id' => $this->warehouseB->id, + 'machine_id' => $this->machineB->id, + 'status' => 'pending', + 'created_by' => $this->userB->id + ]); + + // 切換為 User A 請求列印 User B 的補貨單 + $this->actingAs($this->userA); + $response = $this->get("/admin/warehouses/replenishments/{$replenishmentB->id}/print"); + + // 由於 TenantScoped Trait 自動隔離,會直接拋出 ModelNotFoundException 進而由 Laravel 轉換為 404 + $response->assertStatus(404); + } +}