修复:恢复拼多多营销账户账单筛选导出 #66

Merged
WuYongJie merged 1 commits from fix/WuYongJie-pinduoduo-bills-export into main 2026-08-17 23:11:56 +08:00
5 changed files with 305 additions and 0 deletions

View File

@ -338,6 +338,11 @@ class Pages extends Controller
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
$pageConfig = collect($sectionConfig['pages'] ?? [])->firstWhere('slug', $page);
abort_if(empty($pageConfig), 403, '当前账号没有该页面权限。');
if ($page === 'marketing-account-bills') {
return $this->exportMarketingAccountBills($request, $section, $page, $tab);
}
abort_unless($page === 'marketing-account-balance', 404);
abort_unless(in_array($tab, ['taobao-alipay', 'pinduoduo'], true), 404);
@ -403,6 +408,91 @@ class Pages extends Controller
]);
}
private function exportMarketingAccountBills(
Request $request,
string $section,
string $page,
string $tab,
) {
abort_unless($tab === 'pinduoduo', 404);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = in_array($request->query('flow_type'), ['income', 'expense'], true)
? (string) $request->query('flow_type')
: '';
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType !== '' ? $flowType : null,
];
$redirectParameters = array_filter([
'section' => $section,
'page' => $page,
'tab' => $tab,
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
], static fn (mixed $value): bool => $value !== '' && $value !== null);
$repository = app(PinduoduoSpreadBillRepository::class);
$csv = null;
try {
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '拼多多账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建拼多多账单导出临时文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, ['店铺名称', '金额(元)', '余额(元)', '摘要', '收支类型', '资金类型', '费用时间']);
foreach ($repository->exportRows(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
) as $row) {
fputcsv($csv, [
$row['store_name'] ?? '',
$row['amount'] ?? 0,
$row['balance'] ?? 0,
$row['summary'] ?? '',
$row['income_expense_type'] ?? '',
$row['fund_type'] ?? '',
$row['cost_time'] ?? '',
]);
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '拼多多账单导出暂时无法完成,请稍后重试。');
}
$range = ($fromDate ?: 'all') . '_' . ($toDate ?: 'all');
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, 'pinduoduo_account_bills_' . $range . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForBoardDeductions(): array
{
$filters = [

View File

@ -140,6 +140,51 @@ class PinduoduoSpreadBillRepository
];
}
/**
* 按当前筛选条件分批读取完整镜像数据,供工作区导出使用。
*
* 导出只允许读取已完成首次同步的共享业务镜像,避免一次用户操作
* 绕过生产缓存和限流后直接向上游数据库发起大量分页查询。
*
* @return \Generator<int,array<string,mixed>>
*/
public function exportRows(
?string $storeName = null,
?string $fromDate = null,
?string $toDate = null,
?string $flowType = null,
int $batchSize = 200,
): \Generator {
if (! $this->usesBusinessMirror()) {
throw new \RuntimeException('拼多多账单共享业务镜像尚未完成同步。');
}
$batchSize = max(1, min(500, $batchSize));
$page = 1;
$exported = 0;
do {
$result = $this->mirror->paginatePinduoduo(
$storeName,
$fromDate,
$toDate,
$flowType,
$page,
$batchSize,
);
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : [];
$total = max(0, (int) ($result['total'] ?? 0));
foreach ($rows as $row) {
yield $this->normalize($row);
}
$count = count($rows);
$exported += $count;
$page++;
} while ($count > 0 && $exported < $total);
}
/**
* 当前筛选条件下的汇总。
*

View File

@ -85,6 +85,15 @@
$currentFrom = (string) ($filters['from'] ?? '');
$currentTo = (string) ($filters['to'] ?? '');
$currentFlowType = (string) ($filters['flow_type'] ?? '');
$exportParameters = array_filter([
'section' => request()->route('section') ?? 'billing-transactions',
'page' => request()->route('page') ?? 'marketing-account-bills',
'tab' => 'pinduoduo',
'store_name' => $currentStore,
'from' => $currentFrom,
'to' => $currentTo,
'flow_type' => $currentFlowType,
], static fn ($value) => $value !== '');
@endphp
<form method="GET" action="{{ request()->url() }}"
class="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
@ -129,6 +138,12 @@
<span class="material-icons text-base" aria-hidden="true">clear</span>
清除
</a>
<a href="{{ route('business.pages.export', $exportParameters) }}"
class="bill-filter-secondary"
aria-label="导出当前筛选结果">
<span class="material-icons text-base" aria-hidden="true">download</span>
导出
</a>
</div>
@if ($currentStore !== '' || $currentFrom !== '' || $currentTo !== '' || $currentFlowType !== '')
<div class="ml-auto self-center text-xs text-gray-500">

View File

@ -87,6 +87,103 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase
->assertSee('class="my-6 business-page--marketing-account-bills"', false);
}
public function testPinduoduoBillsPageShowsExportForTheCurrentFilters(): void
{
$this->allowMarketingAccountBillsPage();
$repository = Mockery::mock(PinduoduoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
$repository->shouldReceive('paginate')->once()->andReturn(['rows' => [], 'total' => 0]);
$repository->shouldReceive('summary')->once()->andReturn([
'income_total' => 0,
'expense_total' => 0,
'net' => 0,
'count' => 0,
]);
$repository->shouldReceive('storeOptions')->once()->andReturn(['筛选店铺']);
$this->app->instance(PinduoduoSpreadBillRepository::class, $repository);
$cache = Mockery::mock(UpstreamQueryCache::class);
$cache->shouldNotReceive('remember');
$factory = Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache);
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$parameters = [
'section' => 'billing-transactions',
'page' => 'marketing-account-bills',
'tab' => 'pinduoduo',
'store_name' => '筛选店铺',
'from' => '2026-07-01',
'to' => '2026-07-31',
'flow_type' => 'expense',
];
$this->loginAs()
->get(route('business.pages.show', $parameters))
->assertOk()
->assertSeeText('导出')
->assertSee(e(route('business.pages.export', $parameters)), false)
->assertSee('aria-label="导出当前筛选结果"', false);
}
public function testPinduoduoBillsExportUsesTheCurrentMirrorFilters(): void
{
$this->allowMarketingAccountBillsPage();
$repository = Mockery::mock(PinduoduoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue();
$repository->shouldReceive('exportRows')
->once()
->with('筛选店铺', '2026-07-01', '2026-07-31', 'expense')
->andReturn((function (): \Generator {
yield [
'store_name' => '筛选店铺',
'amount' => 12.34,
'balance' => 56.78,
'summary' => '推广支出',
'income_expense_type' => '支出',
'fund_type' => '现金',
'cost_time' => '2026-07-31 23:59:59',
];
})());
$this->app->instance(PinduoduoSpreadBillRepository::class, $repository);
$response = $this->loginAs()->get(route('business.pages.export', [
'section' => 'billing-transactions',
'page' => 'marketing-account-bills',
'tab' => 'pinduoduo',
'store_name' => '筛选店铺',
'from' => '2026-07-01',
'to' => '2026-07-31',
'flow_type' => 'expense',
]));
$response->assertOk()->assertDownload('pinduoduo_account_bills_2026-07-01_2026-07-31.csv');
$content = $response->streamedContent();
$this->assertStringContainsString('店铺名称,金额(元),余额(元),摘要,收支类型,资金类型,费用时间', $content);
$this->assertStringContainsString('筛选店铺,12.34,56.78,推广支出,支出,现金,"2026-07-31 23:59:59"', $content);
}
public function testPinduoduoBillsExportExplainsWhenTheMirrorIsNotReady(): void
{
$this->allowMarketingAccountBillsPage();
$repository = Mockery::mock(PinduoduoSpreadBillRepository::class);
$repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse();
$this->app->instance(PinduoduoSpreadBillRepository::class, $repository);
$this->loginAs()
->get(route('business.pages.export', [
'section' => 'billing-transactions',
'page' => 'marketing-account-bills',
'tab' => 'pinduoduo',
'store_name' => '筛选店铺',
]))
->assertRedirect(route('business.pages.show', [
'section' => 'billing-transactions',
'page' => 'marketing-account-bills',
'tab' => 'pinduoduo',
'store_name' => '筛选店铺',
]))
->assertSessionHas('error', '拼多多账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
public function testJdWalletHasRechargeAndConsumptionViewsWithoutChangingTheDataSource(): void
{
$this->allowMarketingAccountBillsPage();

View File

@ -3,6 +3,7 @@
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\MarketingAccountBillMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\TestCase;
@ -119,4 +120,61 @@ class PinduoduoSpreadBillRepositoryTest extends TestCase
$this->assertStringContainsString('p.createTime < m.month_start', $query->calls[1]['sql']);
$this->assertSame(['拼多多_测试店铺', '2026-06-01', '2026-06-01', 26, 50], $query->calls[1]['bindings']);
}
public function test_export_rows_reads_every_filtered_mirror_batch(): void
{
$mirror = new class extends MarketingAccountBillMirrorRepository {
public array $calls = [];
public function __construct() {}
public function ready(string $datasetKey): bool
{
TestCase::assertSame(MarketingAccountBillMirrorRepository::PINDUODUO, $datasetKey);
return true;
}
public function paginatePinduoduo(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
int $page,
int $pageSize,
): array {
$this->calls[] = compact('storeName', 'fromDate', 'toDate', 'flowType', 'page', 'pageSize');
$row = static fn (int $id): array => [
'id' => $id,
'shopName' => '筛选店铺',
'amount' => $id * 10,
'balance' => 100,
'summary' => '测试导出',
'flowType' => 1,
'toAdvertiserFundType' => 1,
'createTime' => '2026-07-23 12:00:00',
];
return $page === 1
? ['rows' => [$row(1), $row(2)], 'total' => 3]
: ['rows' => [$row(3)], 'total' => 3];
}
};
$query = new UpstreamReadOnlyQuery('jiansun');
$factory = new class($query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; }
};
$repository = new PinduoduoSpreadBillRepository($factory, $mirror);
$rows = iterator_to_array($repository->exportRows(
'筛选店铺', '2026-07-01', '2026-07-31', 'income', 2,
));
$this->assertCount(3, $rows);
$this->assertSame([10.0, 20.0, 30.0], array_column($rows, 'amount'));
$this->assertSame([1, 2], array_column($mirror->calls, 'page'));
$this->assertSame(['筛选店铺', '筛选店铺'], array_column($mirror->calls, 'storeName'));
$this->assertSame(['income', 'income'], array_column($mirror->calls, 'flowType'));
}
}