CaiWuXiTong/tests/Feature/Business/AccountMappingPageTest.php

415 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Tests\Feature\FeatureTestCase;
/**
* 财务规则 · 科目映射表页面。
*
* 注意:这张表的数据落在 storage_path('app/account-mapping'),
* 所以每个用例都把 storage 指到临时目录,绝不污染真实 storage。
* 种子仍取仓库里的 resources/data/account-mapping.json。
*/
class AccountMappingPageTest extends FeatureTestCase
{
private const SEED_TOTAL = 112;
private string $storageRoot = '';
protected function setUp(): void
{
parent::setUp();
$this->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('共 <b id="amTotal">' . self::SEED_TOTAL . '</b> 条规则', false)
->assertSee('var ROWS = [', false)
->assertSee('"platform":"得物"', false)
->assertSeeText('科目映射表')
// 序号列表头固定为「序号」且宽度 3.5rem:2.5rem 时三位数会被省略号截成「1…」
->assertSee('class="am-index" style="width:3.5rem;">序号</th>', 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_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']);
}
}