CaiWuXiTong/tests/Feature/Business/WithdrawalManagementImportExportTest.php

563 lines
24 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 App\Models\Business\StoreReceivingAccount;
use App\Business\WithdrawalManagementReport;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Mockery;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
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',
'店铺主体',
'提现路径',
'提现账户名称',
'银行卡号(后四位)',
];
private const IMPORT_HEADERS = [...self::HEADERS, '提现金额'];
/** @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::IMPORT_HEADERS, $spreadsheet->getSheet(0)->rangeToArray('A1:H1')[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);
}
public function test_compliance_uses_balance_minus_current_date_withdrawal_and_matches_export(): void
{
Carbon::setTestNow('2026-09-24 09:00:00');
try {
$stores = [];
foreach ([
['达标测试店', '5000.00', '2026-09-23'],
['未达标测试店', '4999.99', '2026-09-23'],
['过期金额测试店', '5000.00', '2026-09-22'],
['缺少金额测试店', null, null],
['缺少余额测试店', '5000.00', '2026-09-23'],
] as [$name, $amount, $date]) {
$stores[$name] = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '京东',
'erp_store_name' => $name,
'store_entity_name' => '测试主体',
'withdrawal_amount' => $amount,
'withdrawal_balance_date' => $date,
]);
}
$stores['缺少标准测试店'] = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '唯品会',
'erp_store_name' => '缺少标准测试店',
'store_entity_name' => '测试主体',
'withdrawal_amount' => '5000.00',
'withdrawal_balance_date' => '2026-09-23',
]);
$balances = [];
foreach ($stores as $store) {
$balances[(int) $store->id] = [
'status' => $store->erp_store_name === '缺少余额测试店' ? 'unmatched' : 'matched',
'balance' => $store->erp_store_name === '缺少余额测试店' ? null : 10000.00,
'source_store_name' => $store->erp_store_name,
'source_platform' => '京东',
];
}
$mirror = Mockery::mock(JushuitanShopBalanceRepository::class);
$mirror->shouldReceive('latestBalanceDate')->andReturn('2026-09-23');
$mirror->shouldReceive('availableDates')->andReturn(['2026-09-23', '2026-09-22']);
$mirror->shouldReceive('matchesForStores')->andReturn($balances);
$mirror->shouldReceive('countForDate')->andReturn(6);
$this->app->instance(JushuitanShopBalanceRepository::class, $mirror);
$report = $this->app->make(WithdrawalManagementReport::class)->results(request());
$this->assertSame('yes', $report['compliance'][(int) $stores['达标测试店']->id]);
$this->assertSame('no', $report['compliance'][(int) $stores['未达标测试店']->id]);
$this->assertNull($report['compliance'][(int) $stores['过期金额测试店']->id]);
$this->assertNull($report['compliance'][(int) $stores['缺少金额测试店']->id]);
$this->assertNull($report['compliance'][(int) $stores['缺少余额测试店']->id]);
$this->assertNull($report['compliance'][(int) $stores['缺少标准测试店']->id]);
$page = $this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '京东',
]));
$page->assertOk()
->assertSeeText('待核验');
$this->assertMatchesRegularExpression('/data-withdrawal-column="compliance">\s*<span class="biz-badge biz-badge-yes">是<\/span>/', $page->getContent());
$this->assertMatchesRegularExpression('/data-withdrawal-column="compliance">\s*<span class="biz-badge biz-badge-no">否<\/span>/', $page->getContent());
$response = $this->get(route('business.withdrawal-management.export', ['platform' => '京东']));
$path = $this->temporaryPath('withdrawal-compliance-export-');
file_put_contents($path, $response->streamedContent());
$spreadsheet = IOFactory::load($path);
try {
$sheet = $spreadsheet->getActiveSheet();
$values = [];
for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
$values[(string) $sheet->getCell("B{$row}")->getValue()] = [
$sheet->getCell("J{$row}")->getValue(),
$sheet->getCell("K{$row}")->getValue(),
];
}
$this->assertSame([5000.0, '是'], $values['达标测试店']);
$this->assertSame([4999.99, '否'], $values['未达标测试店']);
$this->assertSame(['待接入', '待核验'], $values['过期金额测试店']);
$this->assertSame(['待接入', '待核验'], $values['缺少金额测试店']);
$this->assertSame([5000.0, '待核验'], $values['缺少余额测试店']);
} finally {
$spreadsheet->disconnectWorksheets();
}
} finally {
Carbon::setTestNow();
}
}
public function test_withdrawal_amount_import_is_validated_and_bound_to_current_balance_date(): void
{
Carbon::setTestNow('2026-09-24 09:00:00');
try {
$store = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '京东',
'erp_store_name' => '金额导入测试店',
'store_entity_name' => '测试主体',
]);
$this->loginAs();
$this->withExceptionHandling()
->post(route('business.withdrawal-management.import.preview'), [
'import' => $this->workbook([['京东', '金额导入测试店', '', '', '', '', '', '-1']]),
])
->assertSessionHasErrors('import');
$this->assertNull($store->fresh()->withdrawal_amount);
$previewResponse = $this->post(route('business.withdrawal-management.import.preview'), [
'import' => $this->workbook([['京东', '金额导入测试店', '', '', '', '', '', '5000.00']]),
]);
$previewResponse->assertSessionHas('withdrawalImportPreview', function (array $preview): bool {
return $preview['preview'][0]['changes'] === '提现金额';
});
$preview = $previewResponse->getSession()->get('withdrawalImportPreview');
$this->post(route('business.withdrawal-management.import.commit'), [
'preview_token' => $preview['token'],
])->assertSessionHasNoErrors();
$this->assertSame('5000.00', $store->fresh()->withdrawal_amount);
$this->assertSame('2026-09-23', $store->fresh()->withdrawal_balance_date);
} finally {
Carbon::setTestNow();
}
}
public function test_page_defaults_to_the_latest_balance_and_supports_a_date_filter(): void
{
Carbon::setTestNow('2026-10-20 10:00:00');
try {
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '天猫',
'erp_store_name' => '五天保留测试店',
'shop_name' => '五天保留测试店',
'store_entity_name' => '五天保留测试主体',
]);
$this->insertBalanceSnapshot('2026-10-15', '2026-10-16 18:31:00', '天猫-五天保留测试店', 12345.67);
$this->insertBalanceSnapshot('2026-10-14', '2026-10-15 18:31:00', '天猫-五天保留测试店', 99999.99);
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '天猫',
]));
$historyResponse = $this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
'platform' => '天猫',
'balance_date' => '2026-10-14',
]));
$response->assertOk()
->assertSeeText('10月20日店铺余额')
->assertSeeText('12,345.67')
->assertDontSeeText('99,999.99')
->assertSee('余额日期:2026-10-15', false);
$historyResponse->assertOk()
->assertSeeText('10月14日店铺余额')
->assertSeeText('99,999.99')
->assertDontSeeText('12,345.67')
->assertSee('余额日期:2026-10-14', false);
} finally {
Carbon::setTestNow();
}
}
private function insertBalanceSnapshot(
string $balanceDate,
string $importedAt,
string $storeName,
float $balance,
): void {
DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([
'balance_date' => $balanceDate,
'source_scope' => 'all',
'source_platform' => '天猫',
'store_name' => $storeName,
'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName),
'platform_account_id' => 'feature-' . $balanceDate,
'balance' => $balance,
'source_row_key' => hash('sha256', $balanceDate . '|' . $storeName),
'source_file_name' => 'feature-fixture.json',
'source_sha256' => str_repeat('d', 64),
'imported_at' => Carbon::parse($importedAt),
'created_at' => Carbon::parse($importedAt),
'updated_at' => Carbon::parse($importedAt),
]);
}
/** @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::IMPORT_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();
}
}