feat: add refund data export
This commit is contained in:
parent
4e702bfa45
commit
18ad5ec089
@ -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 = [
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user