415 lines
17 KiB
PHP
415 lines
17 KiB
PHP
<?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']);
|
||
}
|
||
}
|