diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index c36cc18f..de08cdf1 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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 = [ diff --git a/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php b/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php index 022179a8..0530a948 100644 --- a/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php +++ b/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php @@ -140,6 +140,51 @@ class PinduoduoSpreadBillRepository ]; } + /** + * 按当前筛选条件分批读取完整镜像数据,供工作区导出使用。 + * + * 导出只允许读取已完成首次同步的共享业务镜像,避免一次用户操作 + * 绕过生产缓存和限流后直接向上游数据库发起大量分页查询。 + * + * @return \Generator> + */ + 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); + } + /** * 当前筛选条件下的汇总。 * diff --git a/resources/views/banking/marketing_pinduoduo_bills/partials/embedded-table.blade.php b/resources/views/banking/marketing_pinduoduo_bills/partials/embedded-table.blade.php index 9faea714..45ed7b07 100644 --- a/resources/views/banking/marketing_pinduoduo_bills/partials/embedded-table.blade.php +++ b/resources/views/banking/marketing_pinduoduo_bills/partials/embedded-table.blade.php @@ -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
@@ -129,6 +138,12 @@ 清除 + + + 导出 + @if ($currentStore !== '' || $currentFrom !== '' || $currentTo !== '' || $currentFlowType !== '')
diff --git a/tests/Feature/Business/MarketingAccountBillsTabsTest.php b/tests/Feature/Business/MarketingAccountBillsTabsTest.php index 537a171f..721b84fd 100644 --- a/tests/Feature/Business/MarketingAccountBillsTabsTest.php +++ b/tests/Feature/Business/MarketingAccountBillsTabsTest.php @@ -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(); diff --git a/tests/Unit/EcommerceFinance/PinduoduoSpreadBillRepositoryTest.php b/tests/Unit/EcommerceFinance/PinduoduoSpreadBillRepositoryTest.php index 5bfa9272..e975428f 100644 --- a/tests/Unit/EcommerceFinance/PinduoduoSpreadBillRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/PinduoduoSpreadBillRepositoryTest.php @@ -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')); + } }