From e0532a539ff7b4cbc436de5888b1f4715b7249de Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 23 Jun 2026 17:23:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT]=20=E5=94=AE=E5=AE=8C=E5=8D=B3?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E8=B2=A8=E9=81=93=E6=95=88=E6=9C=9F=E8=88=87?= =?UTF-8?q?=E6=89=B9=E8=99=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. recordDispense 扣庫存後,若 stock 歸 0 則一併清空 expiry_date 與 batch_no,避免舊批效期殘留:日後僅補 stock 未改效期時,update_inventory 不會把過期日下發、誤將新批貨鎖成「暫不販售」,與機台端「賣完清效期」對稱 2. 新增 ExpiryClearOnSelloutMysqlTest 驗證售完清效期行為 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Transaction/TransactionService.php | 11 ++ .../ExpiryClearOnSelloutMysqlTest.php | 101 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/Feature/Transaction/ExpiryClearOnSelloutMysqlTest.php diff --git a/app/Services/Transaction/TransactionService.php b/app/Services/Transaction/TransactionService.php index f96e78f..4354430 100644 --- a/app/Services/Transaction/TransactionService.php +++ b/app/Services/Transaction/TransactionService.php @@ -305,6 +305,17 @@ class TransactionService if ($slot) { $slot->decrement('stock'); + // 售完(庫存歸 0)即清空效期與批號,避免舊批貨效期殘留。 + // 場景:日後管理員只補 stock 未改 expiry_date 時,update_inventory 才不會 + // 把過期日一起下發、把新批貨誤鎖成「暫不販售」。與機台端「賣完清效期」對稱。 + // 純加法:僅在仍有殘留值時才寫,不影響既有扣庫存行為。 + if ($slot->stock <= 0 && ($slot->expiry_date !== null || $slot->batch_no !== null)) { + $slot->update([ + 'expiry_date' => null, + 'batch_no' => null, + ]); + } + $type = \App\Models\Machine\MachineStockMovement::TYPE_SALE; $this->machineService->recordStockMovement( diff --git a/tests/Feature/Transaction/ExpiryClearOnSelloutMysqlTest.php b/tests/Feature/Transaction/ExpiryClearOnSelloutMysqlTest.php new file mode 100644 index 0000000..1bfe5dd --- /dev/null +++ b/tests/Feature/Transaction/ExpiryClearOnSelloutMysqlTest.php @@ -0,0 +1,101 @@ +getDriverName() !== 'mysql') { + $this->markTestSkipped('需 MySQL:machine_stock_movements.type enum 為 MySQL-only DDL。'); + } + + $this->company = Company::create(['name' => 'Test Company']); + $this->service = app(TransactionService::class); + } + + private function dispense(string $serial, string $slotNo): array + { + return [ + 'serial_no' => $serial, + 'slot_no' => $slotNo, + 'product_id' => 101, + 'amount' => 50, + 'dispense_status' => 1, + ]; + } + + /** 庫存賣到 0 → 效期與批號被清空。 */ + public function test_clears_expiry_and_batch_when_stock_hits_zero(): void + { + $serial = '1234567890'; + $machine = Machine::factory()->create([ + 'company_id' => $this->company->id, + 'serial_no' => $serial, + ]); + $slot = $machine->slots()->create([ + 'slot_no' => '1', + 'stock' => 1, + 'max_stock' => 10, + 'expiry_date' => '2030-12-31', + 'batch_no' => 'B-001', + ]); + + $this->service->recordDispense($this->dispense($serial, '1')); + + $fresh = $slot->fresh(); + $this->assertEquals(0, $fresh->stock, '庫存應扣為 0'); + $this->assertNull($fresh->expiry_date, '售完應清空效期'); + $this->assertNull($fresh->batch_no, '售完應清空批號'); + } + + /** 庫存尚未歸 0 → 效期與批號保留(同批貨還在販售)。 */ + public function test_keeps_expiry_when_stock_remains(): void + { + $serial = '1234567891'; + $machine = Machine::factory()->create([ + 'company_id' => $this->company->id, + 'serial_no' => $serial, + ]); + $slot = $machine->slots()->create([ + 'slot_no' => '2', + 'stock' => 5, + 'max_stock' => 10, + 'expiry_date' => '2030-12-31', + 'batch_no' => 'B-001', + ]); + + $this->service->recordDispense($this->dispense($serial, '2')); + + $fresh = $slot->fresh(); + $this->assertEquals(4, $fresh->stock, '庫存應扣為 4'); + $this->assertEquals('2030-12-31', $fresh->expiry_date->format('Y-m-d'), '未售完應保留效期'); + $this->assertEquals('B-001', $fresh->batch_no, '未售完應保留批號'); + } +} From 5e0bfeb544a63c2aba5821cefb929f817df1abbd Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 25 Jun 2026 20:03:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?[FIX]=20=E9=8A=B7=E5=94=AE=E7=B4=80?= =?UTF-8?q?=E9=8C=84=E6=A9=9F=E5=8F=B0=E6=AC=8A=E9=99=90=E9=9A=94=E9=9B=A2?= =?UTF-8?q?=EF=BC=9A=E8=A8=82=E5=96=AE/=E7=99=BC=E7=A5=A8/=E5=87=BA?= =?UTF-8?q?=E8=B2=A8=E6=B8=85=E5=96=AE=E4=BE=9D=E4=BD=BF=E7=94=A8=E8=80=85?= =?UTF-8?q?=E5=8F=AF=E5=AD=98=E5=8F=96=E6=A9=9F=E5=8F=B0=E9=81=8E=E6=BF=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. SalesController index 先前 orders/invoices/dispense 查詢只靠 TenantScoped 做 company 隔離,未依機台授權過濾;導致無該機台權限的子帳號仍能看到其交易訂單(機台名稱因 machine_access scope 顯示 unknown,但訂單仍列出) 2. 修正:非系統管理員時,三個查詢一律限制 machine_id 在「可存取機台集合」(沿用 Machine 的 machine_access 全域 scope,與機台下拉清單一致);export 共用同一 query 亦一併涵蓋 3. 新增 SalesMachineAccessTest(3 案,Admin 套件 58 測試 0 回歸) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Admin/SalesController.php | 10 ++ .../Feature/Admin/SalesMachineAccessTest.php | 100 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/Feature/Admin/SalesMachineAccessTest.php diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php index 6fdc97c..a85d1b2 100644 --- a/app/Http/Controllers/Admin/SalesController.php +++ b/app/Http/Controllers/Admin/SalesController.php @@ -85,6 +85,16 @@ class SalesController extends Controller $invoicesQuery->whereBetween('created_at', [$start, $end]); $dispenseQuery->whereBetween('machine_time', [$start, $end]); + // 機台權限隔離:非系統管理員只能看到「自己可存取機台」的交易。沿用 Machine 的 machine_access 全域 scope + // 取得可存取機台集合(與上方機台下拉清單一致),修正訂單/發票/出貨清單先前未依機台授權過濾、 + // 導致無該機台權限的帳號仍可見其交易紀錄的資料隔離漏洞。 + if (!auth()->user()->isSystemAdmin()) { + $accessibleMachineIds = Machine::pluck('id'); + $ordersQuery->whereIn('machine_id', $accessibleMachineIds); + $invoicesQuery->whereIn('machine_id', $accessibleMachineIds); + $dispenseQuery->whereIn('machine_id', $accessibleMachineIds); + } + // 共用過濾器:機台 if ($machineId) { $ordersQuery->where('machine_id', $machineId); diff --git a/tests/Feature/Admin/SalesMachineAccessTest.php b/tests/Feature/Admin/SalesMachineAccessTest.php new file mode 100644 index 0000000..8a3a120 --- /dev/null +++ b/tests/Feature/Admin/SalesMachineAccessTest.php @@ -0,0 +1,100 @@ + 'menu.sales', 'guard_name' => 'web']); + $this->company = Company::create(['name' => 'Co', 'code' => 'CO']); + $role = Role::create(['name' => 'r', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']); + $role->syncPermissions(['menu.sales']); + + $this->main = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => true, 'level' => 0]); + $this->main->assignRole($role); + + $this->mA = Machine::factory()->create(['company_id' => $this->company->id]); + $this->mB = Machine::factory()->create(['company_id' => $this->company->id]); + + $this->orderA = $this->makeOrder($this->mA->id); + $this->orderB = $this->makeOrder($this->mB->id); + } + + private function makeOrder(int $machineId): int + { + return DB::table('orders')->insertGetId([ + 'company_id' => $this->company->id, + 'machine_id' => $machineId, + 'payment_type' => 1, + 'total_amount' => 100, + 'pay_amount' => 100, + 'payment_status' => 1, + 'status' => 'completed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function visibleOrderIds(User $user): array + { + $response = $this->actingAs($user)->get(route('admin.sales.index')); + $response->assertOk(); + return collect($response->viewData('orders')->items())->pluck('id')->all(); + } + + public function test_sub_account_with_machine_access_sees_only_that_machine_orders(): void + { + $sub = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]); + $sub->assignRole(Role::where('company_id', $this->company->id)->first()); + $sub->machines()->attach($this->mA->id); // 只被授權 mA + + $ids = $this->visibleOrderIds($sub); + $this->assertContains($this->orderA, $ids); + $this->assertNotContains($this->orderB, $ids); // 未授權 mB → 看不到其訂單 + } + + public function test_sub_account_without_any_machine_access_sees_no_orders(): void + { + $sub = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]); + $sub->assignRole(Role::where('company_id', $this->company->id)->first()); + // 不授權任何機台 + + $ids = $this->visibleOrderIds($sub); + $this->assertNotContains($this->orderA, $ids); + $this->assertNotContains($this->orderB, $ids); + } + + public function test_system_admin_sees_all_orders(): void + { + $sysAdmin = User::factory()->create(['company_id' => null]); + + $ids = $this->visibleOrderIds($sysAdmin); + $this->assertContains($this->orderA, $ids); + $this->assertContains($this->orderB, $ids); + } +}