diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index a5619bd5..2bdd83a5 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -254,19 +254,7 @@ class Pages extends Controller $request = request(); $defaultTo = now()->toDateString(); $defaultFrom = now()->subDays(6)->toDateString(); - $from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom; - $to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo; - if ($from > $to) { - [$from, $to] = [$to, $from]; - } - - $filters = [ - 'from' => $from, - 'to' => $to, - 'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100), - 'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 191), - 'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100), - ]; + $filters = $this->nonJingxiRefundFilters($request, $defaultFrom, $defaultTo); $page = max((int) $request->query('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 100); $rows = new LengthAwarePaginator([], 0, $limit, 1, [ @@ -315,6 +303,145 @@ class Pages extends Controller ]; } + public function exportNonJingxiRefunds(Request $request) + { + $filters = $this->nonJingxiRefundFilters($request); + $redirectParameters = array_filter([ + 'section' => 'refunds', + 'page' => 'non-jingxi-refund-data', + ...$filters, + ], static fn (mixed $value): bool => $value !== '' && $value !== null); + + try { + if (! $this->nonJingxiRefunds->available()) { + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '共享业务镜像尚未建立,售后(退货退款)暂时不能导出。'); + } + + if (! $this->nonJingxiRefunds->ready()) { + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '业务镜像首次同步尚未完成,售后(退货退款)暂时不能导出。'); + } + + if (! $this->nonJingxiRefunds->queryReady()) { + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '页面查询索引尚未就绪,售后(退货退款)暂时不能导出。'); + } + + return $this->streamNonJingxiRefundCsv( + $this->nonJingxiRefunds->exportRows($filters), + $filters, + $redirectParameters, + ); + } catch (Throwable $exception) { + report($exception); + + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。'); + } + } + + /** @return array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} */ + private function nonJingxiRefundFilters( + Request $request, + ?string $defaultFrom = null, + ?string $defaultTo = null, + ): array { + $defaultTo ??= now()->toDateString(); + $defaultFrom ??= now()->subDays(6)->toDateString(); + $from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom; + $to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo; + if ($from > $to) { + [$from, $to] = [$to, $from]; + } + + return [ + 'from' => $from, + 'to' => $to, + 'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100), + 'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 191), + 'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100), + ]; + } + + private function streamNonJingxiRefundCsv(iterable $rows, array $filters, array $redirectParameters) + { + $columns = [ + ['label' => '序号/主键ID', 'field' => 'Id'], + ['label' => '退款日期', 'field' => 'refundDate'], + ['label' => '售后单ID', 'field' => 'as_id'], + ['label' => '外部售后单号', 'field' => 'outer_as_id'], + ['label' => '订单ID', 'field' => 'o_id'], + ['label' => '', 'field' => ''], + ['label' => '子订单ID', 'field' => 'so_id'], + ['label' => '类型', 'field' => 'type'], + ['label' => '节点', 'field' => 'node'], + ['label' => '是否自营', 'field' => 'isZC'], + ['label' => '店铺名称', 'field' => 'shop_name'], + ['label' => '平台', 'field' => 'platform'], + ['label' => '责任部门', 'field' => 'responsibleDepartment'], + ['label' => '退款总金额', 'field' => 'totalRefundMoney', 'number' => true], + ['label' => '原始邮编', 'field' => 'originPostcode'], + ['label' => '店铺商品ID', 'field' => 'shop_i_id'], + ['label' => '店铺SKU编码', 'field' => 'shop_sku_id'], + ['label' => '商品ID', 'field' => 'i_id'], + ['label' => 'SKU编码', 'field' => 'sku_id'], + ['label' => '买家实付金额', 'field' => 'buyer_paid_amount', 'number' => true], + ['label' => '数量', 'field' => 'qty', 'number' => true], + ['label' => '商品单位成本', 'field' => 'unitCostGoods', 'number' => true], + ['label' => '明细退款金额', 'field' => 'detailRefundMoney', 'number' => true], + ['label' => '退款类型', 'field' => 'refundType'], + ['label' => '退款数量', 'field' => 'refundQty', 'number' => true], + ['label' => '退款商品成本', 'field' => 'refundCostGoods', 'number' => true], + ['label' => '退款回款金额', 'field' => 'RefundMoneyIncome', 'number' => true], + ['label' => '售后金额', 'field' => 'AfSalesMoney', 'number' => true], + ['label' => '创建时间', 'field' => 'createTime'], + ['label' => '状态', 'field' => 'status'], + ['label' => '店铺状态', 'field' => 'shop_status'], + ['label' => '创建记录/创建时间', 'field' => 'created'], + ['label' => '确认日期', 'field' => 'confirm_date'], + ]; + + $csv = null; + + try { + $csv = fopen('php://temp/maxmemory:5242880', 'w+b'); + if ($csv === false) { + throw new \RuntimeException('无法创建售后退款导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_column($columns, 'label')); + foreach ($rows as $row) { + fputcsv($csv, array_map( + fn (array $column): mixed => $column['field'] === '' + ? '' + : (($column['number'] ?? false) + ? data_get($row, $column['field']) + : $this->csvText(data_get($row, $column['field']))), + $columns, + )); + } + rewind($csv); + } catch (Throwable $exception) { + if (is_resource($csv)) { + fclose($csv); + } + report($exception); + + return redirect()->route('business.pages.show', $redirectParameters) + ->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。'); + } + + return response()->streamDownload(function () use ($csv): void { + fpassthru($csv); + fclose($csv); + }, 'after_sales_refunds_' . $filters['from'] . '_' . $filters['to'] . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + protected function contentForMarketingAccountBalance(?string $tab): array { $content = [ diff --git a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php index fe1ed510..97414a4d 100644 --- a/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/NonJingxiRefundMirrorRepository.php @@ -148,16 +148,7 @@ class NonJingxiRefundMirrorRepository $total = (clone $query)->count(); $rows = $query - ->select([ - 'i.Id as Id', 'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id', - 'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name', - 'r.shop_site as platform', 'r.refund as totalRefundMoney', - 'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id', 'i.qty', - 'i.shop_amount as detailRefundMoney', 'i.type as refundType', - 'i.r_qty as refundQty', 'r.receive_refund as RefundMoneyIncome', - 'r.buyer_apply_refund as AfSalesMoney', 'r.createTime', 'r.status', - 'r.shop_status', 'r.created', 'r.confirm_date', - ]) + ->select($this->selectColumns()) ->orderByDesc('r.as_date') ->orderByDesc('r.Id') ->orderByDesc('i.Id') @@ -169,6 +160,62 @@ class NonJingxiRefundMirrorRepository return ['data' => $rows->all(), 'total' => $total]; } + /** + * Export all rows matching the supplied filters in the same order and + * shape as the paginated page data, without loading the full result set. + * + * @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters + * @return iterable + */ + public function exportRows(array $filters, int $chunkSize = 100): iterable + { + if (! $this->ready() || ! $this->queryReady()) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $chunkSize = min(max($chunkSize, 1), 100); + $query = $this->baseQuery(); + $this->applyFilters($query, $filters); + + $page = 1; + do { + $rows = (clone $query) + ->select($this->selectColumns()) + ->orderByDesc('r.as_date') + ->orderByDesc('r.Id') + ->orderByDesc('i.Id') + ->forPage($page, $chunkSize) + ->get(); + + if ($rows->isEmpty()) { + break; + } + + $this->hydratePage($rows); + + foreach ($rows as $row) { + yield $row; + } + + $page++; + } while ($rows->count() === $chunkSize); + } + + /** @return list */ + private function selectColumns(): array + { + return [ + 'i.Id as Id', 'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id', + 'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name', + 'r.shop_site as platform', 'r.refund as totalRefundMoney', + 'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id', 'i.qty', + 'i.shop_amount as detailRefundMoney', 'i.type as refundType', + 'i.r_qty as refundQty', 'r.receive_refund as RefundMoneyIncome', + 'r.buyer_apply_refund as AfSalesMoney', 'r.createTime', 'r.status', + 'r.shop_status', 'r.created', 'r.confirm_date', + ]; + } + private function baseQuery(): Builder { return $this->connection->table('ops_jst_return_refund_info as r') diff --git a/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php b/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php index 106e268d..aa342d79 100644 --- a/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php +++ b/resources/views/business/pages/refunds/non-jingxi-refund-data.blade.php @@ -132,6 +132,14 @@ 重置 + total() === 0 || $refundError || $refundPending || $refundIndexPending) aria-disabled="true" @endif + > + + 导出筛选结果 + diff --git a/routes/admin.php b/routes/admin.php index 038fdb6d..607ebe84 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -92,6 +92,10 @@ Route::get('workspace/product-inventory/normal-products/export', 'Business\Pages ->middleware('business.page:product-inventory,normal-products') ->name('business.product-inventory.normal-products.export'); +Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@exportNonJingxiRefunds') + ->middleware('business.page:refunds,non-jingxi-refund-data') + ->name('business.refunds.non-jingxi-refund-data.export'); + Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export') ->name('business.pages.export'); diff --git a/tests/Feature/Business/NonJingxiRefundPageTest.php b/tests/Feature/Business/NonJingxiRefundPageTest.php index 50495f43..25bdf05c 100644 --- a/tests/Feature/Business/NonJingxiRefundPageTest.php +++ b/tests/Feature/Business/NonJingxiRefundPageTest.php @@ -100,10 +100,77 @@ class NonJingxiRefundPageTest extends FeatureTestCase ->assertSeeText('99.50') ->assertSeeText('共 26 条记录') ->assertSeeText('第 2 / 2 页') + ->assertSeeText('导出筛选结果') + ->assertSee('workspace/refunds/non-jingxi-refund-data/export', false) ->assertSee('value="2026-08-01"', false) ->assertSee('value="ORDER-1"', false); } + public function test_refund_export_downloads_filtered_csv(): void + { + $this->grantAdminPermission(); + $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('queryReady')->once()->andReturnTrue(); + $repository->shouldReceive('exportRows')->once()->with([ + 'from' => '2026-08-01', + 'to' => '2026-08-21', + 'order_no' => 'ORDER-1', + 'shop_name' => '测试店', + 'sku_id' => 'SKU-1', + ])->andReturn([(object) [ + 'Id' => 102, + 'refundDate' => '2026-08-20 10:00:00', + 'as_id' => 2002, + 'outer_as_id' => 'AS-2002', + 'o_id' => 3002, + 'so_id' => 'ORDER-1', + 'type' => '退款', + 'node' => '完成', + 'isZC' => '是', + 'shop_name' => '测试店', + 'platform' => '天猫', + 'responsibleDepartment' => '电商部', + 'totalRefundMoney' => 99.5, + 'originPostcode' => 'ZIP', + 'shop_i_id' => 'ITEM-1', + 'shop_sku_id' => 'SHOP-SKU-1', + 'i_id' => 'STYLE-1', + 'sku_id' => 'SKU-1', + 'buyer_paid_amount' => 120, + 'qty' => 1, + 'unitCostGoods' => 50, + 'detailRefundMoney' => 99.5, + 'refundType' => '退货退款', + 'refundQty' => 1, + 'refundCostGoods' => 50, + 'RefundMoneyIncome' => 99.5, + 'AfSalesMoney' => 99.5, + 'createTime' => '2026-08-20 09:00:00', + 'status' => '完成', + 'shop_status' => '完成', + 'created' => '2026-08-20 09:00:00', + 'confirm_date' => '2026-08-20 10:00:00', + ]]); + $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route('business.refunds.non-jingxi-refund-data.export', [ + 'from' => '2026-08-01', + 'to' => '2026-08-21', + 'order_no' => 'ORDER-1', + 'shop_name' => '测试店', + 'sku_id' => 'SKU-1', + ])); + + $response->assertOk()->assertDownload('after_sales_refunds_2026-08-01_2026-08-21.csv'); + $content = $response->streamedContent(); + $this->assertStringStartsWith("\xEF\xBB\xBF", $content); + $this->assertStringContainsString('序号/主键ID', $content); + $this->assertStringContainsString('AS-2002', $content); + $this->assertStringContainsString('99.5', $content); + } + public function test_page_does_not_report_empty_data_before_initial_sync_finishes(): void { $this->grantAdminPermission(); @@ -158,7 +225,9 @@ class NonJingxiRefundPageTest extends FeatureTestCase ])) ->assertOk() ->assertSeeText('当前筛选范围暂无数据。') - ->assertSeeText('共 0 条记录'); + ->assertSeeText('共 0 条记录') + ->assertSee('is-disabled', false) + ->assertSeeText('导出筛选结果'); } public function test_page_does_not_run_the_query_before_required_indexes_exist(): void diff --git a/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php index 2ae7c23e..6dae89aa 100644 --- a/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php @@ -92,6 +92,12 @@ class NonJingxiRefundMirrorRepositoryTest extends TestCase $filters['sku_id'] = ''; $filters['order_no'] = '8902'; $this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']); + + $filters['order_no'] = ''; + $exportRows = iterator_to_array($this->repository->exportRows($filters, 1)); + $this->assertCount(2, $exportRows); + $this->assertSame(12, $exportRows[0]->Id); + $this->assertSame(11, $exportRows[1]->Id); } public function test_sync_status_distinguishes_rotation_from_initial_sync(): void