修复:恢复拼多多营销账户账单筛选导出 #66
@ -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 = [
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前筛选条件下的汇总。
|
||||
*
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user