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); } 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)); } /** 账套里查不到的一级科目必须是「错误」,否则自动生成分录时会静默失败 */ 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')); } }