*/ 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->post(route('business.labor-expenses.save', $routeParameters), ['rows' => []]) ->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#<=0,0,IF(AN#<=4000,800,AN#*0.2))', false) ->assertSee('=IF(J#<=0,"",IF(J#<=3000,0.03', false) ->assertSee('=IF(J#<=0,"",IF(J#<=3000,0,IF(J#<=12000,210', false) ->assertSee('=J#*K#-L#', false) ->assertSee('=IF(F#<=0,"",IF(F#<=8500,5025', false) ->assertSee('=O#*0.12', false) ->assertSee('=Q#-H#', false) ->assertSee('=S#*T#-U#', false) ->assertSee('=IF(T#<=0.03,0,IF(T#<=0.1,3000,12000))', false) ->assertSee('=IF(F#<=0,0,IF(O#=10000', false) ->assertSee('=IF(F#<=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( '/]*data-labor-group="base"[^>]*data-finance-fixed="true"[^>]*>/s', $html ); $this->assertMatchesRegularExpression( '/]*data-labor-action-group[^>]*data-finance-fixed="end"[^>]*>/s', $html ); foreach (['A', 'B', 'C', 'D', 'E'] as $column) { $this->assertMatchesRegularExpression( '/]*data-labor-column="' . $column . '"[^>]*data-finance-fixed="true"[^>]*>/s', $html ); } $this->assertStringContainsString( "const fixedStartColumns = ['A', 'B', 'C', 'D', 'E'];", $html ); $this->assertStringContainsString( 'const amountInputText = (value) => {', $html ); $this->assertStringContainsString( "input.value = field.kind === 'number' ? amountInputText(storedValue) : String(storedValue);", $html ); $this->assertStringContainsString( 'const formatted = parsed.toFixed(2);', $html ); $this->assertMatchesRegularExpression( '/th\[data-labor-group="base"\]\s*\{[^}]*padding-inline:\s*1rem;[^}]*letter-spacing:\s*0\.12em;/s', $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*\{[^}]*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( '/]*data-labor-column="' . $column . '"[^>]*data-labor-group="' . $group . '"[^>]*>/s', $html ); } } public function test_labor_expense_rows_can_be_saved_and_reloaded(): void { $chenxi = $this->account('晨曦'); $routeParameters = [ 'section' => 'financial-operations', 'page' => 'labor-expenses', ]; $response = $this->withExceptionHandling() ->loginAs($chenxi) ->withHeaders(['Accept' => 'application/json']) ->post(route('business.labor-expenses.save', $routeParameters), [ 'source_name' => '8月工资.xlsx', 'rows' => [[ 'A' => '1', 'B' => '测试花名', 'C' => '测试姓名', 'D' => '测试中心', 'E' => '测试职位', 'F' => 10000.5, 'G' => 800, 'H' => 500, 'J' => 999, ]], ]); $response->assertOk() ->assertJsonPath('row_count', 1) ->assertJsonPath('source_name', '8月工资.xlsx') ->assertJsonPath('rows.0.B', '测试花名') ->assertJsonPath('rows.0.C', '测试姓名') ->assertJsonPath('rows.0.F', 10000.5) ->assertJsonMissingPath('rows.0.J'); $snapshot = DB::table('business_labor_expense_snapshots') ->where('company_id', $this->company->id) ->first(); $this->assertNotNull($snapshot); $this->assertSame(1, (int) $snapshot->row_count); $this->assertSame('8月工资.xlsx', $snapshot->source_name); $this->assertSame( [[ 'B' => '测试花名', 'C' => '测试姓名', 'D' => '测试中心', 'E' => '测试职位', 'F' => 10000.5, 'G' => 800, 'H' => 500, 'BI' => '', ]], json_decode($snapshot->rows, true, 512, JSON_THROW_ON_ERROR), ); $loaded = app(LaborExpenseDataStore::class)->load(); $this->assertSame(1, $loaded['row_count']); $this->assertSame('8月工资.xlsx', $loaded['source_name']); $this->assertSame('测试花名', $loaded['rows'][0]['B']); $this->loginAs($chenxi) ->get(route('business.pages.show', $routeParameters)) ->assertOk() ->assertSee('data-labor-save', false) ->assertSee('data-labor-save-state', false); $this->withExceptionHandling() ->loginAs($chenxi) ->withHeaders(['Accept' => 'application/json']) ->post(route('business.labor-expenses.save', $routeParameters), [ 'rows' => [], ]) ->assertOk() ->assertJsonPath('row_count', 0) ->assertJsonPath('rows', []); $this->assertDatabaseHas('business_labor_expense_snapshots', [ 'company_id' => $this->company->id, 'row_count' => 0, ]); } public function test_labor_expense_save_rejects_invalid_numeric_values(): void { $chenxi = $this->account('晨曦'); $response = $this->withExceptionHandling() ->loginAs($chenxi) ->withHeaders(['Accept' => 'application/json']) ->post(route('business.labor-expenses.save', [ 'section' => 'financial-operations', 'page' => 'labor-expenses', ]), [ 'rows' => [[ 'B' => '测试花名', 'F' => '不是数字', ]], ]); $response->assertUnprocessable() ->assertJsonValidationErrors('rows'); $this->assertDatabaseMissing('business_labor_expense_snapshots', [ 'company_id' => $this->company->id, ]); } 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(); } }