[FIX] 同步 hotfix:銷售紀錄機台權限隔離

訂單/發票/出貨清單依使用者可存取機台過濾。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sky121113 2026-06-25 20:06:17 +08:00
commit 5a4d681d13
4 changed files with 222 additions and 0 deletions

View File

@ -85,6 +85,16 @@ class SalesController extends Controller
$invoicesQuery->whereBetween('created_at', [$start, $end]); $invoicesQuery->whereBetween('created_at', [$start, $end]);
$dispenseQuery->whereBetween('machine_time', [$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) { if ($machineId) {
$ordersQuery->where('machine_id', $machineId); $ordersQuery->where('machine_id', $machineId);

View File

@ -313,6 +313,17 @@ class TransactionService
if ((int) $slot->stock > 0) { if ((int) $slot->stock > 0) {
$slot->decrement('stock'); $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; $type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
$this->machineService->recordStockMovement( $this->machineService->recordStockMovement(

View File

@ -0,0 +1,100 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 銷售紀錄機台權限隔離:使用者只能看到「自己可存取機台」的交易訂單,
* 無該機台權限者不得看到其交易(修正先前訂單清單未依機台授權過濾的漏洞)。
*/
class SalesMachineAccessTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private User $main;
private Machine $mA;
private Machine $mB;
private int $orderA;
private int $orderB;
protected function setUp(): void
{
parent::setUp();
Permission::create(['name' => '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);
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 售完(庫存歸 0)自動清空效期/批號 —— MySQLrecordDispense 內部寫
* machine_stock_movements type enum MySQL-only DDLsqlite :memory: 無法驗)。執行:
*
* ./vendor/bin/sail test -c phpunit.mysql.xml --filter ExpiryClearOnSelloutMysqlTest
*
* 在預設 sqlite 套件中自動 skip,不影響主套件綠燈。
*
* 動機:機台端「賣完清效期」已做,但 B009 補貨上報不回傳 expiry後台若殘留舊效期
* 日後管理員只補 stock 未改效期 update_inventory 把過期日下發 新批貨被誤鎖。
* 故後台扣庫存歸 0 時亦須清效期,與機台端對稱。
*/
class ExpiryClearOnSelloutMysqlTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
if (DB::connection()->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, '未售完應保留批號');
}
}