CaiWuXiTong/tests/Feature/Business/WithdrawalManagementImportExportTest.php
92451 a5a17a5c13 feat: add withdrawal table controls (#258)
> 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。

## 任务占用与边界

- 任务名称:
- 实际开发者:
- 任务来源/团队分工:
- 目标结果:
- 预计修改路径:
- 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件):
- 仅复用、明确不修改的公共契约:
- 数据源、表/视图和字段:
- 上游 `source_key` / `scope`:
- 权限名、菜单键或共享组件:
- 明确不修改的范围:

## 开工碰撞检查

- [ ] Codex 已执行 `scripts/codex-task-preflight.ps1`
- [ ] 已检查最新 `main` 和所有远程任务分支
- [ ] 已搜索主线既有页面、接口、Repository 和数据查询
- [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件
- [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论

相关分支、重叠项与协调结论:

## 实际改动

- 完成内容:
- Codex 实际修改的文件:
- 新增或变更的公共契约:
- 与原计划的差异:

## 验证证据

- 正常流程:
- 空数据/错误输入/无权限场景:
- 受影响旧功能:
- 自动测试及结果:
- 实际页面、接口或只读业务查询结果:

## 风险检查

- [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复
- [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权
- [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用”

高风险影响、回滚和授权状态:

## 合并前复查

- [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查
- [ ] PR 不再是 `WIP:`,所有测试结果已经补齐
- [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff
- [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果
- [ ] 合并顺序不会覆盖其他未合并任务
- [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支
- [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/258
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-09-23 22:02:02 +08:00

362 lines
14 KiB
PHP

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class WithdrawalManagementImportExportTest extends FeatureTestCase
{
private const HEADERS = [
'店铺平台',
'ERP店铺名称',
'平台店铺ID',
'店铺主体',
'提现路径',
'提现账户名称',
'银行卡号(后四位)',
];
/** @var list<string> */
private array $temporaryFiles = [];
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail(),
);
$this->withoutMiddleware(ThrottleRequests::class);
}
public function test_page_exposes_import_and_filtered_export_actions(): void
{
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '天猫',
'erp_store_name' => '操作按钮测试店',
'store_entity_name' => '按钮测试主体',
]);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '天猫',
]))
->assertOk()
->assertSeeText('导入')
->assertSeeText('导出')
->assertSee('data-withdrawal-import-open', false)
->assertSee('class="withdrawal-management__actions"', false)
->assertSee('data-withdrawal-column-trigger', false)
->assertSee('data-withdrawal-column-toggle', false)
->assertSee('data-withdrawal-sort-asc', false)
->assertSee('data-withdrawal-sort-desc', false)
->assertSee(route('business.withdrawal-management.export', ['platform' => '天猫']), false)
->assertSee(route('business.withdrawal-management.template'), false);
}
public function test_page_sorts_all_filtered_rows_before_pagination(): void
{
foreach (['B排序测试店', 'A排序测试店'] as $name) {
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '排序测试平台',
'erp_store_name' => $name,
'store_entity_name' => '排序测试主体',
]);
}
$ascending = $this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '排序测试平台',
'sort' => 'erp_store_name',
'direction' => 'asc',
]));
$descending = $this->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '排序测试平台',
'sort' => 'erp_store_name',
'direction' => 'desc',
]));
$ascending
->assertOk()
->assertSeeInOrder(['A排序测试店', 'B排序测试店'])
->assertSee('name="sort" value="erp_store_name"', false)
->assertSee('name="direction" value="asc"', false)
->assertSee('aria-sort="ascending"', false);
$descending
->assertOk()
->assertSeeInOrder(['B排序测试店', 'A排序测试店'])
->assertSee('aria-sort="descending"', false);
}
public function test_export_contains_all_filtered_rows_and_excludes_passwords(): void
{
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '天猫',
'erp_store_name' => '导出目标店',
'store_id' => '00001234',
'store_entity_name' => '导出主体',
'login_password' => 'never-export-this-password',
'withdrawal_path' => '支付宝',
'withdrawal_account_name' => '目标账户',
'bank_card_last_four' => '0098',
]);
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '京东',
'erp_store_name' => '不应导出的店铺',
'store_entity_name' => '其他主体',
]);
$response = $this->loginAs()->get(route('business.withdrawal-management.export', [
'platform' => '天猫',
]));
$response->assertOk()->assertDownload();
$path = $this->temporaryPath('withdrawal-export-');
file_put_contents($path, $response->streamedContent());
$spreadsheet = IOFactory::load($path);
try {
$sheet = $spreadsheet->getActiveSheet();
$this->assertSame([
...self::HEADERS,
now()->format('n月j日') . '店铺余额',
'预留标准',
'提现金额',
'是否达标',
], $sheet->rangeToArray('A1:K1')[0]);
$this->assertSame(2, $sheet->getHighestDataRow());
$this->assertSame('导出目标店', $sheet->getCell('B2')->getValue());
$this->assertSame('00001234', $sheet->getCell('C2')->getValue());
$this->assertSame(DataType::TYPE_STRING, $sheet->getCell('C2')->getDataType());
$this->assertSame('0098', $sheet->getCell('G2')->getValue());
$this->assertSame(DataType::TYPE_STRING, $sheet->getCell('G2')->getDataType());
$values = array_merge(...$sheet->toArray(null, false, false, false));
$this->assertNotContains('不应导出的店铺', $values);
$this->assertNotContains('never-export-this-password', $values);
} finally {
$spreadsheet->disconnectWorksheets();
}
}
public function test_template_has_import_headers_and_instructions(): void
{
$response = $this->loginAs()->get(route('business.withdrawal-management.template'));
$response->assertOk()->assertDownload('withdrawal_management_import_template.xlsx');
$path = $this->temporaryPath('withdrawal-template-');
file_put_contents($path, $response->streamedContent());
$spreadsheet = IOFactory::load($path);
try {
$this->assertSame(self::HEADERS, $spreadsheet->getSheet(0)->rangeToArray('A1:G1')[0]);
$this->assertSame('填写说明', $spreadsheet->getSheet(1)->getTitle());
$this->assertStringContainsString('[清空]', (string) $spreadsheet->getSheet(1)->getCell('A4')->getValue());
} finally {
$spreadsheet->disconnectWorksheets();
}
}
public function test_valid_import_is_previewed_before_allowed_fields_are_committed(): void
{
$store = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '天猫',
'erp_store_name' => '导入更新店',
'store_id' => 'TM-IMPORT-1',
'store_entity_name' => '原主体',
'login_password' => 'password-remains-private',
'withdrawal_path' => '银行卡',
'withdrawal_account_name' => '原账户',
'bank_card_last_four' => '1111',
]);
$this->loginAs();
$file = $this->workbook([[
'天猫', '导入更新店', 'TM-IMPORT-1', '新主体', '支付宝', '新账户', '0022',
]]);
$previewResponse = $this->post(route('business.withdrawal-management.import.preview', [
'platform' => '天猫',
]), ['import' => $file]);
$previewResponse
->assertRedirect(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '天猫',
]))
->assertSessionHas('withdrawalImportPreview', function (array $preview): bool {
return $preview['total'] === 1
&& $preview['changed'] === 1
&& $preview['unchanged'] === 0
&& $preview['preview'][0]['changes'] === '店铺主体、提现路径、提现账户名称、银行卡号后四位';
});
$this->assertSame('原主体', $store->fresh()->store_entity_name);
$preview = $previewResponse->getSession()->get('withdrawalImportPreview');
$this->post(route('business.withdrawal-management.import.commit', [
'platform' => '天猫',
]), ['preview_token' => $preview['token']])
->assertRedirect()
->assertSessionHas('success', '提现配置导入完成,共更新 1 家店铺。');
$store = $store->fresh();
$this->assertSame('新主体', $store->store_entity_name);
$this->assertSame('支付宝', $store->withdrawal_path);
$this->assertSame('新账户', $store->withdrawal_account_name);
$this->assertSame('0022', $store->bank_card_last_four);
$this->assertSame('password-remains-private', $store->login_password);
}
public function test_invalid_bank_card_rejects_the_entire_preview_without_writes(): void
{
$store = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '京东',
'erp_store_name' => '非法卡号测试店',
'store_id' => 'JD-INVALID-1',
'store_entity_name' => '原主体',
'withdrawal_path' => '银行卡',
]);
$this->loginAs();
$this->withExceptionHandling()
->post(route('business.withdrawal-management.import.preview'), ['import' => $this->workbook([[
'京东', '非法卡号测试店', 'JD-INVALID-1', '不应写入的主体', '', '', '12ab',
]])])
->assertRedirect()
->assertSessionHasErrors('import');
$this->assertSame('原主体', $store->fresh()->store_entity_name);
}
public function test_clear_marker_clears_nullable_and_required_configuration_fields(): void
{
$store = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '拼多多',
'erp_store_name' => '清空配置测试店',
'store_id' => 'PDD-CLEAR-1',
'store_entity_name' => '待清空主体',
'withdrawal_path' => '银行卡',
'withdrawal_account_name' => '待清空账户',
'bank_card_last_four' => '8899',
]);
$this->loginAs();
$previewResponse = $this->post(route('business.withdrawal-management.import.preview'), [
'import' => $this->workbook([[
'拼多多', '清空配置测试店', 'PDD-CLEAR-1', '[清空]', '[清空]', '[清空]', '[清空]',
]]),
]);
$preview = $previewResponse->getSession()->get('withdrawalImportPreview');
$this->post(route('business.withdrawal-management.import.commit'), [
'preview_token' => $preview['token'],
])->assertRedirect()->assertSessionHasNoErrors();
$store = $store->fresh();
$this->assertSame('', $store->getRawOriginal('store_entity_name'));
$this->assertSame('', $store->getRawOriginal('receiving_method'));
$this->assertNull($store->withdrawal_account_name);
$this->assertNull($store->bank_card_last_four);
}
public function test_broken_workbook_is_reported_as_an_import_error(): void
{
$this->loginAs();
$this->withExceptionHandling()
->post(route('business.withdrawal-management.import.preview'), [
'import' => UploadedFile::fake()->createWithContent('损坏文件.xlsx', "PK\x03\x04broken"),
])
->assertRedirect()
->assertSessionHasErrors([
'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
]);
}
public function test_commit_rejects_a_preview_when_store_changed_after_preview(): void
{
$store = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '抖音',
'erp_store_name' => '并发修改测试店',
'store_id' => 'DY-STALE-1',
'store_entity_name' => '预览前主体',
]);
$this->loginAs();
$previewResponse = $this->post(route('business.withdrawal-management.import.preview'), [
'import' => $this->workbook([[
'抖音', '并发修改测试店', 'DY-STALE-1', '导入主体', '', '', '',
]]),
]);
$preview = $previewResponse->getSession()->get('withdrawalImportPreview');
DB::table('business_store_receiving_accounts')->where('id', $store->id)->update([
'store_entity_name' => '他人已修改主体',
'updated_at' => now()->addMinute(),
]);
$this->withExceptionHandling()
->post(route('business.withdrawal-management.import.commit'), [
'preview_token' => $preview['token'],
])
->assertRedirect()
->assertSessionHasErrors('import');
$this->assertSame('他人已修改主体', $store->fresh()->store_entity_name);
}
/** @param list<list<mixed>> $rows */
private function workbook(array $rows): UploadedFile
{
$path = $this->temporaryPath('withdrawal-import-');
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('提现管理');
$sheet->fromArray(self::HEADERS, null, 'A1');
if ($rows !== []) {
$sheet->fromArray($rows, null, 'A2');
}
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
return new UploadedFile(
$path,
'提现管理.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
null,
true,
);
}
private function temporaryPath(string $prefix): string
{
$path = tempnam(sys_get_temp_dir(), $prefix);
$this->temporaryFiles[] = $path;
return $path;
}
protected function tearDown(): void
{
foreach ($this->temporaryFiles as $path) {
@unlink($path);
}
parent::tearDown();
}
}