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') }}
+
+
+
+
+
+
+
+
+
+
+
+
Star Cloud System
+
Tenant: {{ Auth::user()->company?->name ?? __('Platform Operator') }}
+
+
+
{{ __('Replenishment Order') }}
+
{{ __('Vending Machine Product Replenishment') }}
+
+
+
+
+
+ {{ __($order->status) }}
+
+
+
+
{{ __('Order Number') }}
+
{{ $order->order_no }}
+
+
+
+
+
+
+
+
{{ __('Source Warehouse') }}
+
+
+
+
+
{{ __('Target Machine') }}
+
+
+
+
+
{{ __('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') }})
+
+
+
+
+ |
+ {{ __('Slot') }}
+ |
+
+ {{ __('Image') }}
+ |
+
+ {{ __('Product Name') }}
+ |
+
+ {{ __('Barcode') }}
+ |
+
+ {{ __('Stock Snap') }}
+ |
+
+ {{ __('Replenish Qty') }}
+ |
+
+ {{ __('Check') }}
+ |
+
+
+ {{ __('Actual Qty') }}
+ |
+
+
+
+ @foreach($items as $index => $item)
+
+ |
+
+ {{ $item->slot_no }}
+
+ |
+
+
+ @if($item->product?->image_url)
+ 
+ @else
+
+ @endif
+
+ |
+
+
+ ID: {{ $item->product_id }}
+ |
+
+ {{ $item->product?->barcode ?? '-' }}
+ |
+
+ {{ $item->current_stock }} / {{ $item->max_stock }}
+ |
+
+ x{{ $item->quantity }}
+ |
+
+
+
+ |
+
+
+
+ |
+
+ @endforeach
+
+
+
+
+
+
+
+
+
+
{{ __('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') }}
+
+
+
+
+
+
+
+
+
+
+
+
Star Cloud System
+
Tenant: {{ Auth::user()->company?->name ?? __('Platform Operator') }}
+
+
+
{{ __('Transfer Order') }}
+
+ {{ $order->type === \App\Models\Warehouse\TransferOrder::TYPE_W2W ? __('Warehouse to Warehouse Transfer') : __('Machine to Warehouse Return') }}
+
+
+
+
+
+
+ {{ __($order->status) }}
+
+
+
+
{{ __('Order Number') }}
+
{{ $order->order_no }}
+
+
+
+
+
+
+
+ @if($order->type === \App\Models\Warehouse\TransferOrder::TYPE_W2W)
+
{{ __('Source Warehouse') }}
+
+ @else
+
{{ __('Source Machine') }}
+
+ @endif
+
+
+
+
{{ __('Target Warehouse') }}
+
+
+
+
+
{{ __('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') }})
+
+
+
+
+ |
+ #
+ |
+
+ {{ __('Image') }}
+ |
+
+ {{ __('Product Name') }}
+ |
+
+ {{ __('Barcode') }}
+ |
+
+ {{ __('Transfer Qty') }}
+ |
+
+ {{ __('Check') }}
+ |
+
+
+ {{ __('Actual Qty') }}
+ |
+
+
+
+ @foreach($items as $index => $item)
+
+ |
+ {{ $index + 1 }}
+ |
+
+
+ @if($item->product?->image_url)
+ 
+ @else
+
+ @endif
+
+ |
+
+
+ ID: {{ $item->product_id }}
+ |
+
+ {{ $item->product?->barcode ?? '-' }}
+ |
+
+ x{{ $item->quantity }}
+ |
+
+
+
+ |
+
+
+
+ |
+
+ @endforeach
+
+
+
+
+
+
+
+
+
+
{{ __('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);
+ }
+}