[FIX] 同步 hotfix:銷售紀錄機台權限隔離
訂單/發票/出貨清單依使用者可存取機台過濾。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5a4d681d13
@ -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);
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
100
tests/Feature/Admin/SalesMachineAccessTest.php
Normal file
100
tests/Feature/Admin/SalesMachineAccessTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
tests/Feature/Transaction/ExpiryClearOnSelloutMysqlTest.php
Normal file
101
tests/Feature/Transaction/ExpiryClearOnSelloutMysqlTest.php
Normal 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)自動清空效期/批號 —— 需 MySQL(recordDispense 內部寫
|
||||||
|
* machine_stock_movements,其 type enum 為 MySQL-only DDL,sqlite :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, '未售完應保留批號');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user