storageRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'account-mapping-' . uniqid(); File::makeDirectory($this->storageRoot . '/app', 0777, true, true); // 只影响 storage_path(),视图编译目录已在启动时解析,不受影响 $this->app->useStoragePath($this->storageRoot); // Rules now live in the in-memory test database; seed it independently of local files. app(AccountMappingStore::class)->reset(); \Illuminate\Support\Facades\DB::table(AccountMappingStore::EVENTS_TABLE)->delete(); } protected function tearDown(): void { if ($this->storageRoot !== '' && File::isDirectory($this->storageRoot)) { File::deleteDirectory($this->storageRoot); } parent::tearDown(); } private function grantAdminPermission(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } private function pageUrl(): string { return route('business.pages.show', [ 'company_id' => $this->company->id, 'section' => 'financial-rules', 'page' => 'account-mapping', ]); } /** .../actions */ private function actionsBase(): string { // 取 rules 路由再砍掉尾巴,等价于控制器里给前端下发端点的方式 return (string) preg_replace('#/rules$#', '', route( 'business.financial-rules.account-mapping.actions.rules', ['company_id' => $this->company->id] )); } /** .../actions/rules[/{id}] */ private function rulesUrl(?int $id = null): string { return $this->actionsBase() . '/rules' . ($id === null ? '' : '/' . $id); } /** .../actions/{preview|import} */ private function actionUrl(string $action): string { return $this->actionsBase() . '/' . $action; } /** * 页面必须把种子规则和校验汇总**渲染进 HTML**。 * 只断言 200 是不够的:注入数据为空时页面照样 200,之前就踩过这个坑。 */ public function test_page_renders_seeded_rules_and_validation_summary(): void { $this->grantAdminPermission(); $this->loginAs()->get($this->pageUrl()) ->assertOk() ->assertSee('data-account-mapping="true"', false) ->assertSee('共 ' . self::SEED_TOTAL . ' 条规则', false) ->assertSee('var ROWS = [', false) ->assertSee('"platform":"得物"', false) ->assertSeeText('科目映射表') // 序号列表头固定为「序号」且宽度 3.5rem:2.5rem 时三位数会被省略号截成「1…」 ->assertSee('class="am-index" style="width:3.5rem;">序号', false) // ENDPOINT 经 @json 输出,斜杠会被转义成 \/,所以只断言变量名 ->assertSee('var ENDPOINT =', false); } public function test_rules_endpoint_returns_seed_rules_and_summary(): void { $this->grantAdminPermission(); $this->loginAs()->getJson($this->rulesUrl()) ->assertOk() ->assertJsonCount(self::SEED_TOTAL, 'rules') ->assertJsonPath('summary.total', self::SEED_TOTAL) ->assertJsonStructure(['rules', 'summary', 'options' => ['platforms', 'categories', 'basis', 'invoices']]); } public function test_can_create_update_and_delete_a_rule(): void { $this->grantAdminPermission(); $payload = ['values' => [ 'platform' => '自测平台', 'item' => '自测账单项目', 'memo' => '自测备注', 'invoice' => '不开票', 'category' => '费用', 'dAcct' => '销售费用', 'dSub' => '运费', 'cAcct' => '其他货币资金', 'cSub' => '', 'basis' => '支出基准', ]]; $created = $this->loginAs()->postJson($this->rulesUrl(), $payload); $created->assertOk()->assertJsonPath('summary.total', self::SEED_TOTAL + 1); $id = collect($created->json('rules'))->firstWhere('platform', '自测平台')['id']; $this->assertIsInt($id); $this->loginAs()->patchJson($this->rulesUrl($id), [ 'values' => array_merge($payload['values'], ['memo' => '改过的备注']), ])->assertOk()->assertJsonPath('summary.total', self::SEED_TOTAL + 1); $store = app(AccountMappingStore::class); $this->assertSame('改过的备注', $store->find($id)['memo']); $this->loginAs()->deleteJson($this->rulesUrl($id)) ->assertOk() ->assertJsonPath('summary.total', self::SEED_TOTAL); $this->assertNull(app(AccountMappingStore::class)->find($id)); } /** * 造一组「新增 → 修改 → 删除」的操作记录,返回最后那条规则的 id。 */ private function seedHistoryRows(bool $withDelete = true): int { $values = [ 'platform' => '自测平台', 'item' => '自测账单项目', 'memo' => '自测备注', 'invoice' => '不开票', 'category' => '费用', 'dAcct' => '销售费用', 'dSub' => '运费', 'cAcct' => '其他货币资金', 'cSub' => '', 'basis' => '支出基准', ]; $created = $this->loginAs()->postJson($this->rulesUrl(), ['values' => $values])->assertOk(); $id = (int) collect($created->json('rules'))->firstWhere('platform', '自测平台')['id']; $this->loginAs()->patchJson($this->rulesUrl($id), [ 'values' => array_merge($values, ['memo' => '改过的备注']), ])->assertOk(); if ($withDelete) { $this->loginAs()->deleteJson($this->rulesUrl($id))->assertOk(); } return $id; } /** 操作记录要能看到新增/修改/删除,并带改动前后内容与操作人 */ public function test_history_endpoint_lists_changes_and_actor(): void { $this->grantAdminPermission(); $this->seedHistoryRows(); $history = $this->loginAs()->getJson($this->actionUrl('history'))->assertOk()->json(); $this->assertSame(3, $history['total']); $this->assertSame(['delete', 'update', 'create'], array_column($history['events'], 'action')); $this->assertSame(['删除', '修改', '新增'], array_column($history['events'], 'action_label')); $latest = $history['events'][0]; $this->assertSame('自测平台', $latest['platform']); $this->assertSame('自测账单项目', $latest['item']); $this->assertSame($this->user->name, $latest['actor']); $this->assertStringContainsString('删除前:借 销售费用 / 运费', $latest['changes'][0]); // 修改要给出「字段:改前 → 改后」,做账的人才能看出改了什么 $this->assertSame(['财务备注:自测备注 → 改过的备注'], $history['events'][1]['changes']); $this->assertStringContainsString('新增:借 销售费用 / 运费', $history['events'][2]['changes'][0]); } /** 操作记录导出 CSV,且按操作类型筛选(口径与页面一致) */ public function test_history_export_csv_can_filter_by_action(): void { $this->grantAdminPermission(); $this->seedHistoryRows(); $all = $this->loginAs()->get($this->actionUrl('history') . '/export')->streamedContent(); $this->assertStringContainsString('时间,操作,平台,账单项目,财务备注,变更内容,操作人', $all); $this->assertSame(4, substr_count($all, "\n")); $deleted = $this->loginAs()->get($this->actionUrl('history') . '/export?action=delete')->streamedContent(); $this->assertStringContainsString('删除,自测平台,自测账单项目', $deleted); // 只留删除那一条:修改的「改前 → 改后」与新增都不该出现 $this->assertStringNotContainsString('→', $deleted); $this->assertStringNotContainsString('新增:', $deleted); $this->assertSame(2, substr_count($deleted, "\n")); } /** 恢复:被删除的规则要能建回来;已经存在则挡住,不能变成两条 */ public function test_history_restore_recreates_deleted_rule(): void { $this->grantAdminPermission(); $this->seedHistoryRows(); $store = app(AccountMappingStore::class); $this->assertCount(self::SEED_TOTAL, $store->all()); $event = $this->loginAs()->getJson($this->actionUrl('history'))->json('events')[0]; $this->assertSame('delete', $event['action']); $payload = ['index' => 0, 'at' => $event['at'], 'action' => $event['action'], 'id' => $event['id']]; $this->loginAs()->postJson($this->actionUrl('history') . '/restore', $payload) ->assertOk() ->assertJsonPath('summary.total', self::SEED_TOTAL + 1); $restored = collect($store->all())->firstWhere('item', '自测账单项目'); $this->assertNotNull($restored); $this->assertSame('运费', $restored['dSub']); $this->assertSame('改过的备注', $restored['memo']); // 再点一次:表里已经有了,必须挡住 $rows = $this->loginAs()->getJson($this->actionUrl('history'))->json('events'); $index = null; foreach ($rows as $i => $row) { if ($row['at'] === $event['at'] && $row['action'] === $event['action'] && $row['id'] === $event['id']) { $index = $i; break; } } $this->assertNotNull($index); $this->withExceptionHandling(); $repeat = $this->loginAs()->postJson( $this->actionUrl('history') . '/restore', ['index' => $index, 'at' => $event['at'], 'action' => $event['action'], 'id' => $event['id']] ); $repeat->assertStatus(422); $this->assertStringContainsString('已经在表里了', (string) $repeat->json('errors.history.0')); } /** 恢复:修改过的规则要能还原成改前内容 */ public function test_history_restore_reverts_an_update(): void { $this->grantAdminPermission(); $id = $this->seedHistoryRows(false); $store = app(AccountMappingStore::class); $this->assertSame('改过的备注', $store->find($id)['memo']); $rows = $this->loginAs()->getJson($this->actionUrl('history'))->json('events'); $index = null; foreach ($rows as $i => $row) { if ($row['action'] === 'update' && $row['id'] === $id) { $index = $i; break; } } $this->assertNotNull($index); $event = $rows[$index]; $this->loginAs()->postJson($this->actionUrl('history') . '/restore', [ 'index' => $index, 'at' => $event['at'], 'action' => $event['action'], 'id' => $event['id'], ])->assertOk(); $this->assertSame('自测备注', $store->find($id)['memo']); } /** 恢复:记录已经变化(位置对不上)时要拒绝,避免恢复错行 */ public function test_history_timestamps_use_platform_timezone(): void { $this->grantAdminPermission(); // 页面请求会被 App\Utilities\Overrider 按公司设置覆盖 app.timezone(实例上回落成 // Europe/London),记录时间不能跟着跑偏,否则日志会差 7 小时。 $originalAppTimezone = config('app.timezone'); $originalPhpTimezone = date_default_timezone_get(); try { config(['app.timezone' => 'Europe/London']); date_default_timezone_set('Europe/London'); $this->seedHistoryRows(false); $event = $this->loginAs()->getJson($this->actionUrl('history'))->json('events')[0]; $platformTimezone = (string) config('app.platform_timezone', 'UTC'); $recorded = \Illuminate\Support\Carbon::parse($event['at'], $platformTimezone); $expected = \Illuminate\Support\Carbon::now($platformTimezone); $this->assertLessThan( 180, abs($recorded->getTimestamp() - $expected->getTimestamp()), '操作记录的时间没有按平台时区落盘(页面请求的时区覆盖会把它带偏)' ); } finally { config(['app.timezone' => $originalAppTimezone]); date_default_timezone_set($originalPhpTimezone); } } /** 恢复:记录已经变化(位置对不上)时要拒绝,避免恢复错行 */ public function test_history_restore_rejects_stale_target(): void { $this->grantAdminPermission(); $this->seedHistoryRows(); $event = $this->loginAs()->getJson($this->actionUrl('history'))->json('events')[0]; $this->withExceptionHandling(); $response = $this->loginAs()->postJson($this->actionUrl('history') . '/restore', [ 'index' => 0, 'at' => '2000-01-01 00:00:00', 'action' => $event['action'], 'id' => $event['id'], ]); $response->assertStatus(422); $this->assertStringContainsString('已经变化', (string) $response->json('errors.history.0')); } /** 账套里查不到的一级科目必须是「错误」,否则自动生成分录时会静默失败 */ public function test_validator_flags_account_missing_from_chart_of_accounts(): void { $store = app(AccountMappingStore::class); $validator = app(AccountMappingValidator::class); $bad = $store->create([ 'platform' => '自测平台', 'item' => '坏数据', 'dAcct' => '不存在的科目', 'cAcct' => '其他货币资金', 'category' => '费用', 'basis' => '支出基准', 'invoice' => '-', ]); $issues = $validator->validate($store->all())['issues'][$bad['id']] ?? []; $this->assertNotEmpty($issues); $this->assertContains('错误', array_column($issues, 'level')); } /** 导入:先预览(不落盘)再确认;两行都是新增 */ public function test_import_preview_and_confirm_adds_new_rules(): void { $this->grantAdminPermission(); $csv = "平台,账单项目,财务备注,票据口径,大类,借方科目,借方二级,贷方科目,贷方二级,方向基准\n" . "自测平台,自测项目甲,备注甲,未开票,费用,销售费用,运费,其他货币资金,,支出基准\n" . "自测平台,自测项目乙,备注乙,未开票,收入,其他货币资金,,主营业务收入,,收入基准\n"; $preview = $this->loginAs()->post($this->actionUrl('preview'), [ 'import' => UploadedFile::fake()->createWithContent('rules.csv', $csv), ], ['Accept' => 'application/json']); $preview->assertOk() ->assertJsonPath('total', 2) ->assertJsonPath('can_create', 2) ->assertJsonPath('can_overwrite', 0); // 预览不落盘 $this->assertCount(self::SEED_TOTAL, app(AccountMappingStore::class)->all()); $this->loginAs()->post($this->actionUrl('import'), [ 'import' => UploadedFile::fake()->createWithContent('rules.csv', $csv), ], ['Accept' => 'application/json']) ->assertOk() ->assertJsonPath('result.created', 2) ->assertJsonPath('summary.total', self::SEED_TOTAL + 2); } /** 非表格扩展名要被挡下,并给出人话提示 */ public function test_import_rejects_non_spreadsheet_extension(): void { $this->grantAdminPermission(); // FeatureTestCase 默认关掉了异常处理,这里要验 422 就必须把它打开 $this->withExceptionHandling(); $this->loginAs()->post($this->actionUrl('preview'), [ 'import' => UploadedFile::fake()->createWithContent('rules.txt', "a,b\n1,2\n"), ], ['Accept' => 'application/json']) ->assertStatus(422) ->assertJsonPath('errors.import.0', '只支持 xlsx、xls、csv 三种格式,当前是「txt」。'); } /** * 抖音账单带来的 5 个大类并入固定清单(往来=收入基准),票据「0」按不开票入库。 */ public function test_douyin_categories_are_fixed_options_and_zero_invoice_means_no_invoice(): void { $this->grantAdminPermission(); $this->assertSame([ '售后费用' => '支出基准', '平台服务费' => '支出基准', '平台佣金' => '支出基准', '往来' => '收入基准', ], array_intersect_key(AccountMappingStore::CATEGORY_BASIS, array_flip([ '售后费用', '平台服务费', '平台佣金', '往来', ]))); $categories = $this->loginAs()->getJson($this->rulesUrl())->assertOk()->json('options.categories'); foreach (['售后费用', '平台服务费', '平台佣金', '往来'] as $category) { $this->assertContains($category, $categories); } $csv = "平台,账单项目,财务备注,票据,大类,借方科目,贷方科目,方向基准\n" . "自测平台,自测项目丙,备注丙,0,售后费用,销售费用,其他货币资金,支出基准\n"; $this->loginAs()->post($this->actionUrl('import'), [ 'import' => UploadedFile::fake()->createWithContent('rules.csv', $csv), ], ['Accept' => 'application/json']) ->assertOk() ->assertJsonPath('result.created', 1); $rule = collect(app(AccountMappingStore::class)->all())->firstWhere('item', '自测项目丙'); $this->assertNotNull($rule); $this->assertSame('不开票', $rule['invoice']); $this->assertSame('支出基准', $rule['basis']); } /** 9% 票据口径(京东短信服务费)并入常用取值,页面下拉可选且不再报提示。 */ public function test_nine_percent_invoice_is_a_known_option(): void { $this->grantAdminPermission(); $this->assertContains('0.09', AccountMappingStore::INVOICES); $invoices = $this->loginAs()->getJson($this->rulesUrl())->assertOk()->json('options.invoices'); $this->assertContains('0.09', $invoices); $issues = app(AccountMappingValidator::class)->validate([[ 'id' => 9001, 'platform' => '京东', 'item' => '短信服务费', 'memo' => '销售费用手续费', 'invoice' => '0.09', 'category' => '平台服务费', 'dAcct' => '销售费用', 'dSub' => '', 'cAcct' => '其他货币资金', 'cSub' => '', 'basis' => '支出基准', ]])['issues'][9001] ?? []; $this->assertNotContains('invoice_unknown', array_column($issues, 'code')); } /** 唯品会JX 带来的「待处理财产损益」并入固定大类(支出基准),且不再触发基准不一致警告。 */ public function test_pending_property_gain_loss_category_is_fixed_option(): void { $this->grantAdminPermission(); $this->assertSame('支出基准', AccountMappingStore::CATEGORY_BASIS['待处理财产损益'] ?? null); $categories = $this->loginAs()->getJson($this->rulesUrl())->assertOk()->json('options.categories'); $this->assertContains('待处理财产损益', $categories); $issues = app(AccountMappingValidator::class)->validate([[ 'id' => 9002, 'platform' => '唯品会JX', 'item' => '其他明细-盘亏盘盈', 'memo' => '待处理财产损益', 'invoice' => '不开票', 'category' => '待处理财产损益', 'dAcct' => '待处理财产损溢', 'dSub' => '', 'cAcct' => '其他货币资金', 'cSub' => '', 'basis' => '支出基准', ]])['issues'][9002] ?? []; $this->assertNotContains('basis_mismatch', array_column($issues, 'code')); } /** 阿里巴巴「越南企业所得税代扣款」用到的「税费」并入固定大类(支出基准),且不触发基准不一致警告。 */ public function test_tax_category_is_fixed_option(): void { $this->grantAdminPermission(); $this->assertSame('支出基准', AccountMappingStore::CATEGORY_BASIS['税费'] ?? null); $categories = $this->loginAs()->getJson($this->rulesUrl())->assertOk()->json('options.categories'); $this->assertContains('税费', $categories); $issues = app(AccountMappingValidator::class)->validate([[ 'id' => 9003, 'platform' => '阿里巴巴', 'item' => '越南企业所得税代扣款', 'memo' => '越南企业所得税代扣款', 'invoice' => '不开票', 'category' => '税费', 'dAcct' => '应交税费', 'dSub' => '代扣代缴所得税', 'cAcct' => '其他货币资金', 'cSub' => '', 'basis' => '支出基准', ]])['issues'][9003] ?? []; $this->assertNotContains('basis_mismatch', array_column($issues, 'code')); } }