feat: add refund data export

This commit is contained in:
苏木 2026-08-28 13:40:16 +08:00
parent 4e702bfa45
commit 18ad5ec089
6 changed files with 285 additions and 24 deletions

View File

@ -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 = [

View File

@ -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<int,object>
*/
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<string> */
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')

View File

@ -132,6 +132,14 @@
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<a
class="upstream-data-button {{ $rows->total() === 0 || $refundError || $refundPending || $refundIndexPending ? 'is-disabled' : '' }}"
href="{{ $rows->total() === 0 || $refundError || $refundPending || $refundIndexPending ? '#' : route('business.refunds.non-jingxi-refund-data.export', $filters) }}"
@if ($rows->total() === 0 || $refundError || $refundPending || $refundIndexPending) aria-disabled="true" @endif
>
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>导出筛选结果</span>
</a>
</div>
</form>

View File

@ -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');

View File

@ -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

View File

@ -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