CaiWuXiTong/tests/Feature/Business/LaborExpensePageAccessTest.php

406 lines
16 KiB
PHP

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\UploadedFile;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class LaborExpensePageAccessTest extends FeatureTestCase
{
/** @var list<string> */
private array $laborImportFixtures = [];
public function test_labor_expense_page_is_visible_only_to_the_named_accounts(): void
{
$chenxi = $this->account('晨曦');
$chenxiEnglish = $this->account('Chenxi');
$jiangling = $this->account('江陵');
$jianglingEnglish = $this->account('Jiangling');
$other = $this->account('其他财务');
$this->assertTrue($this->pageIsVisible($chenxi));
$this->assertTrue($this->pageIsVisible($chenxiEnglish));
$this->assertTrue($this->pageIsVisible($jiangling));
$this->assertTrue($this->pageIsVisible($jianglingEnglish));
$this->assertFalse($this->pageIsVisible($other));
$this->assertFalse($this->pageIsVisible($this->user));
}
public function test_explicit_page_permission_cannot_bypass_the_viewer_allowlist(): void
{
$other = $this->account('其他财务');
$subject = 'user_' . $other->id;
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [
$subject => ['financial-operations/labor-expenses'],
],
]);
$this->assertFalse($this->pageIsVisible($other));
}
public function test_direct_page_and_generic_actions_use_the_same_allowlist(): void
{
$chenxi = $this->account('晨曦');
$other = $this->account('其他财务');
$routeParameters = [
'section' => 'financial-operations',
'page' => 'labor-expenses',
];
$this->loginAs($chenxi)
->get(route('business.pages.show', $routeParameters))
->assertOk();
$this->withExceptionHandling()->loginAs($other)
->get(route('business.pages.show', $routeParameters))
->assertForbidden();
$this->post(route('business.pages.store', $routeParameters), [])
->assertForbidden();
$this->post(route('business.pages.import', $routeParameters), [])
->assertForbidden();
$this->withExceptionHandling()->loginAs($this->user)
->get(route('business.pages.show', $routeParameters))
->assertForbidden();
}
public function test_page_contains_the_workbook_headers_and_formulas_without_payroll_rows(): void
{
$chenxi = $this->account('晨曦');
$fields = config('labor_expense_table.fields');
$formulaFields = collect($fields)->filter(
static fn (array $field): bool => filled($field['formula'] ?? null)
);
$this->assertCount(61, $fields);
$this->assertCount(52, $formulaFields);
$response = $this->loginAs($chenxi)->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'labor-expenses',
]))->assertOk()
->assertSeeText('人工费用测算表')
->assertSeeText('基础信息')
->assertSeeText('原数据')
->assertSeeText('提高公积金基数方案')
->assertSeeText('工资+兼职方案')
->assertSeeText('汇算清缴')
->assertSee('data-labor-expense', false)
->assertSee('data-labor-expense-table', false)
->assertSee('data-labor-import-form', false)
->assertSee('data-labor-import-open', false)
->assertSee('data-labor-group="base"', false)
->assertSee('data-labor-group="original"', false)
->assertSee('data-labor-group="fund"', false)
->assertSee('data-labor-group="salary_parttime"', false)
->assertSee('data-labor-group="settlement"', false)
->assertSee('data-labor-action-group', false)
->assertSee('=F#-G#-H#-I#', false)
->assertSee('=IF(AN#&lt;=0,0,IF(AN#&lt;=4000,800,AN#*0.2))', false)
->assertSee('=IF(J#&lt;=0,&quot;&quot;,IF(J#&lt;=3000,0.03', false)
->assertSee('=IF(J#&lt;=0,&quot;&quot;,IF(J#&lt;=3000,0,IF(J#&lt;=12000,210', false)
->assertSee('=J#*K#-L#', false)
->assertSee('=IF(F#&lt;=0,&quot;&quot;,IF(F#&lt;=8500,5025', false)
->assertSee('=O#*0.12', false)
->assertSee('=Q#-H#', false)
->assertSee('=S#*T#-U#', false)
->assertSee('=IF(T#&lt;=0.03,0,IF(T#&lt;=0.1,3000,12000))', false)
->assertSee('=IF(F#&lt;=0,0,IF(O#=10000', false)
->assertSee('=IF(F#&lt;=0,0,IF(O#=5025', false)
->assertSee('=AF#*AG#-AH#', false)
->assertSee('=AD#', false)
->assertDontSee('data-labor-row="0"', false);
$html = $response->getContent();
$this->assertSame(61, substr_count($html, 'data-labor-header="'));
$this->assertSame(52, substr_count($html, 'class="labor-expense-formula"'));
$this->assertMatchesRegularExpression(
'/<th[^>]*data-labor-group="base"[^>]*data-finance-fixed="true"[^>]*>/s',
$html
);
$this->assertMatchesRegularExpression(
'/<th[^>]*data-labor-action-group[^>]*data-finance-fixed="end"[^>]*>/s',
$html
);
foreach (['A', 'B', 'C', 'D', 'E'] as $column) {
$this->assertMatchesRegularExpression(
'/<th[^>]*data-labor-column="' .
$column .
'"[^>]*data-finance-fixed="true"[^>]*>/s',
$html
);
}
$this->assertStringContainsString(
"const fixedStartColumns = ['A', 'B', 'C', 'D', 'E'];",
$html
);
foreach ([
'base' => 'base',
'original' => 'original',
'fund' => 'fund',
'salary_parttime' => 'salary-parttime',
'settlement' => 'settlement',
] as $group => $variable) {
$this->assertMatchesRegularExpression(
'/\.labor-expense-table thead tr:first-child th\[data-labor-group="' .
$group .
'"\]\s*\{\s*background:\s*var\(--labor-group-' .
$variable .
'\);/s',
$html
);
$this->assertMatchesRegularExpression(
'/\.labor-expense-table thead tr:last-child th\[data-labor-group="' .
$group .
'"\]\s*\{\s*background:\s*var\(--labor-group-' .
$variable .
'\);/s',
$html
);
}
foreach ([
'B' => 'base',
'J' => 'original',
'O' => 'fund',
'AA' => 'salary_parttime',
'AW' => 'settlement',
] as $column => $group) {
$this->assertMatchesRegularExpression(
'/<th[^>]*data-labor-column="' . $column . '"[^>]*data-labor-group="' . $group . '"[^>]*>/s',
$html
);
}
}
public function test_labor_expense_workbook_import_returns_editable_rows(): void
{
$chenxi = $this->account('晨曦');
$file = $this->laborWorkbook([
['基础信息', '基础信息', '基础信息', '基础信息', '原数据', '原数据', '原数据'],
['花名', '姓名', '中心', '职位', '8月应发工资', '原社保个人扣款', '原公积金个人扣款'],
['测试花名', '测试姓名', '测试中心', '测试职位', 10000.5, 800, 500],
]);
$response = $this->withExceptionHandling()
->loginAs($chenxi)
->withHeaders(['Accept' => 'application/json'])
->post(route('business.pages.import', [
'section' => 'financial-operations',
'page' => 'labor-expenses',
]), ['import' => $file]);
$response->assertOk()
->assertJsonPath('row_count', 1)
->assertJsonPath('rows.0.B', '测试花名')
->assertJsonPath('rows.0.C', '测试姓名')
->assertJsonPath('rows.0.D', '测试中心')
->assertJsonPath('rows.0.E', '测试职位')
->assertJsonPath('rows.0.F', 10000.5)
->assertJsonPath('rows.0.G', 800)
->assertJsonPath('rows.0.H', 500)
->assertJsonMissingPath('rows.0.J');
}
public function test_labor_expense_import_redirect_keeps_rows_for_non_ajax_form(): void
{
$chenxi = $this->account('晨曦');
$file = $this->laborWorkbook([
['花名', '姓名', '8月应发工资', '原社保个人扣款', '原公积金个人扣款'],
['测试花名', '测试姓名', 9000, 600, 400],
]);
$response = $this->loginAs($chenxi)->post(route('business.pages.import', [
'section' => 'financial-operations',
'page' => 'labor-expenses',
]), ['import' => $file]);
$response->assertRedirect(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'labor-expenses',
]))->assertSessionHas('labor_expense_import_rows.0.B', '测试花名')
->assertSessionHas('labor_expense_import_message');
}
public function test_labor_expense_import_rejects_a_file_without_matching_headers(): void
{
$chenxi = $this->account('晨曦');
$file = $this->laborWorkbook([
['错误表头'],
['无法识别'],
]);
$response = $this->withExceptionHandling()
->loginAs($chenxi)
->withHeaders(['Accept' => 'application/json'])
->post(route('business.pages.import', [
'section' => 'financial-operations',
'page' => 'labor-expenses',
]), ['import' => $file]);
$response->assertUnprocessable()
->assertJsonValidationErrors('import');
}
public function test_basic_deduction_tax_amount_and_housing_fund_base_are_formula_driven(): void
{
$fields = collect(config('labor_expense_table.fields'))->keyBy('key');
$this->assertSame('formula_number', $fields['I']['kind']);
$this->assertSame('=5000', $fields['I']['formula']);
$this->assertSame('formula_number', $fields['AZ']['kind']);
$this->assertSame('=60000', $fields['AZ']['formula']);
$this->assertSame('formula_number', $fields['M']['kind']);
$this->assertSame('=J#*K#-L#', $fields['M']['formula']);
$this->assertSame('=M#*12', $fields['N']['formula']);
$this->assertSame('formula_number', $fields['O']['kind']);
$this->assertSame(
[5025, 8000, 10000, 12000],
$this->housingFundBases($fields['O']['formula'])
);
foreach (['P' => '=O#*0.12', 'Q' => '=O#*0.12', 'R' => '=Q#-H#', 'V' => '=S#*T#-U#'] as $key => $formula) {
$this->assertSame('formula_number', $fields[$key]['kind']);
$this->assertSame($formula, $fields[$key]['formula']);
}
$this->assertSame('formula_number', $fields['AA']['kind']);
$this->assertSame('=IF(T#<=0.03,0,IF(T#<=0.1,3000,12000))', $fields['AA']['formula']);
$this->assertSame('formula_number', $fields['AB']['kind']);
$this->assertSame(
'=IF(F#<=0,0,IF(O#=10000,IF(S#-AA#>5000,S#-AA#,0),IF(O#=12000,S#-AA#,0)))',
$fields['AB']['formula']
);
$this->assertSame('formula_number', $fields['AC']['kind']);
$this->assertSame(
'=IF(F#<=0,0,IF(O#=5025,IF(F#<=5025,0,F#-O#),IF(O#=8000,IF(T#<=0.03,0,S#-AA#),IF(O#=10000,IF(S#-AA#<=5000,S#-AA#,0),0))))',
$fields['AC']['formula']
);
$this->assertSame('formula_number', $fields['AI']['kind']);
$this->assertSame('=AF#*AG#-AH#', $fields['AI']['formula']);
$this->assertSame('formula_number', $fields['AN']['kind']);
$this->assertSame('=AD#', $fields['AN']['formula']);
}
public function test_monthly_and_annual_tax_quick_deductions_are_formula_driven(): void
{
$fields = collect(config('labor_expense_table.fields'))->keyBy('key');
foreach (['L', 'U', 'AH', 'Y', 'AL', 'BD'] as $key) {
$this->assertNotEmpty($fields[$key]['formula'] ?? null, "{$key} 应配置速算扣除数公式");
}
foreach (['L' => 'J', 'U' => 'S', 'AH' => 'AF'] as $key => $taxableKey) {
$this->assertSame('formula_number', $fields[$key]['kind']);
$this->assertStringStartsWith("=IF({$taxableKey}#<=0,\"\"", $fields[$key]['formula']);
$this->assertSame(
[0, 210, 1410, 2660, 4410, 7160, 15160],
$this->quickDeductions($fields[$key]['formula'])
);
}
foreach (['Y' => 'W', 'AL' => 'AJ', 'BD' => 'BB'] as $key => $taxableKey) {
$this->assertStringStartsWith("=IF({$taxableKey}#", $fields[$key]['formula']);
$this->assertSame(
[0, 2520, 16920, 31920, 52920, 85920, 181920],
$this->quickDeductions($fields[$key]['formula'])
);
}
}
private function pageIsVisible(User $user): bool
{
$financialOperations = collect(SidebarPermissions::navigationFor($user))
->firstWhere('slug', 'financial-operations');
return collect($financialOperations['pages'] ?? [])
->contains('slug', 'labor-expenses');
}
private function account(string $name): User
{
$suffix = User::count();
$user = User::create([
'name' => $name,
'email' => 'labor-access-' . $suffix . '@example.test',
'password' => 'fixture-unused-password',
'enabled' => true,
'locale' => 'zh-CN',
'landing_page' => 'dashboard',
]);
$user->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
$user->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
$user->companies()->attach($this->company->id);
return $user;
}
private function quickDeductions(string $formula): array
{
preg_match_all('/IF\([A-Z]+#<=\d+,(\d+)/', $formula, $matches);
$deductions = array_values(array_unique(array_map('intval', $matches[1])));
if (preg_match('/,(\d+)\)+\s*$/', $formula, $fallback)) {
$deductions[] = (int) $fallback[1];
}
return $deductions;
}
private function housingFundBases(string $formula): array
{
preg_match_all('/IF\(F#<=\d+,(\d+)/', $formula, $matches);
$bases = array_map('intval', $matches[1]);
if (preg_match('/,(\d+)\)+\s*$/', $formula, $fallback)) {
$bases[] = (int) $fallback[1];
}
return $bases;
}
private function laborWorkbook(array $rows): UploadedFile
{
$temporaryPath = tempnam(sys_get_temp_dir(), 'labor-expense-import-');
if ($temporaryPath === false) {
throw new \RuntimeException('Unable to create labor expense import fixture.');
}
$path = $temporaryPath . '.xlsx';
@unlink($temporaryPath);
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray($rows, null, 'A1');
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
$this->laborImportFixtures[] = $path;
return new UploadedFile(
$path,
'labor-expense.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
null,
true,
);
}
protected function tearDown(): void
{
foreach ($this->laborImportFixtures as $path) {
if (is_file($path)) {
@unlink($path);
}
}
parent::tearDown();
}
}