feat: 统一财务表格列设置与镜像导出 #151

Merged
WuYongJie merged 1 commits from feature/yueyang-finance-table-ui into main 2026-09-10 13:27:29 +08:00
8 changed files with 217 additions and 39 deletions

View File

@ -986,6 +986,7 @@ class Pages extends Controller
$this->nonJingxiRefunds->exportRows($filters),
$filters,
$redirectParameters,
$request->query('columns'),
);
} catch (Throwable $exception) {
report($exception);
@ -1018,16 +1019,16 @@ class Pages extends Controller
];
}
private function streamNonJingxiRefundCsv(iterable $rows, array $filters, array $redirectParameters)
private function streamNonJingxiRefundCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null)
{
$columns = [
['label' => '序号/主键ID', 'field' => 'Id'],
['label' => '序号/主键ID', 'field' => 'Id', 'identifier' => true],
['label' => '退款日期', 'field' => 'refundDate'],
['label' => '售后单ID', 'field' => 'as_id'],
['label' => '外部售后单号', 'field' => 'outer_as_id'],
['label' => '订单ID', 'field' => 'o_id'],
['label' => '售后单ID', 'field' => 'as_id', 'identifier' => true],
['label' => '外部售后单号', 'field' => 'outer_as_id', 'identifier' => true],
['label' => '订单ID', 'field' => 'o_id', 'identifier' => true],
['label' => '', 'field' => ''],
['label' => '子订单ID', 'field' => 'so_id'],
['label' => '子订单ID', 'field' => 'so_id', 'identifier' => true],
['label' => '类型', 'field' => 'type'],
['label' => '节点', 'field' => 'node'],
['label' => '是否自营', 'field' => 'isZC'],
@ -1036,10 +1037,10 @@ class Pages extends Controller
['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' => '店铺商品ID', 'field' => 'shop_i_id', 'identifier' => true],
['label' => '店铺SKU编码', 'field' => 'shop_sku_id', 'identifier' => true],
['label' => '商品ID', 'field' => 'i_id', 'identifier' => true],
['label' => 'SKU编码', 'field' => 'sku_id', 'identifier' => true],
['label' => '买家实付金额', 'field' => 'buyer_paid_amount', 'number' => true],
['label' => '数量', 'field' => 'qty', 'number' => true],
['label' => '商品单位成本', 'field' => 'unitCostGoods', 'number' => true],
@ -1055,6 +1056,7 @@ class Pages extends Controller
['label' => '创建记录/创建时间', 'field' => 'created'],
['label' => '确认日期', 'field' => 'confirm_date'],
];
$columns = $this->filterExportColumns($columns, $requestedFields);
$csv = null;
@ -1072,7 +1074,9 @@ class Pages extends Controller
? ''
: (($column['number'] ?? false)
? data_get($row, $column['field'])
: $this->csvText(data_get($row, $column['field']))),
: (($column['identifier'] ?? false)
? $this->csvIdentifier(data_get($row, $column['field']))
: $this->csvText(data_get($row, $column['field'])))),
$columns,
));
}
@ -1178,6 +1182,7 @@ class Pages extends Controller
$this->smallPaymentWorkOrders->exportRows($filters),
$filters,
$redirectParameters,
$request->query('columns'),
);
} catch (Throwable $exception) {
report($exception);
@ -1214,11 +1219,11 @@ class Pages extends Controller
];
}
private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters)
private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null)
{
$columns = [
['label' => '工单编号', 'field' => 'work_order_no'],
['label' => '支付宝批次号', 'field' => 'alipay_batch_no'],
['label' => '工单编号', 'field' => 'work_order_no', 'identifier' => true],
['label' => '支付宝批次号', 'field' => 'alipay_batch_no', 'identifier' => true],
['label' => '工单状态', 'field' => 'work_order_status'],
['label' => '打款状态', 'field' => 'payment_status'],
['label' => '工单标题', 'field' => 'work_order_title'],
@ -1228,12 +1233,12 @@ class Pages extends Controller
['label' => '打款金额', 'field' => 'payment_amount', 'number' => true],
['label' => '催处理次数', 'field' => 'urging_count', 'number' => true],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '线上订单号', 'field' => 'online_order_no'],
['label' => '线上订单号', 'field' => 'online_order_no', 'identifier' => true],
['label' => '买家账号', 'field' => 'buyer_account'],
['label' => '支付宝账号', 'field' => 'alipay_account'],
['label' => '支付宝姓名', 'field' => 'alipay_name'],
['label' => '商品编码', 'field' => 'product_code'],
['label' => 'SKU编码', 'field' => 'sku_code'],
['label' => '商品编码', 'field' => 'product_code', 'identifier' => true],
['label' => 'SKU编码', 'field' => 'sku_code', 'identifier' => true],
['label' => '平台', 'field' => 'platform'],
['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true],
['label' => '支付时间', 'field' => 'payment_time'],
@ -1242,6 +1247,7 @@ class Pages extends Controller
['label' => '创建时间', 'field' => 'work_order_created_at'],
['label' => '完成时间', 'field' => 'completed_at'],
];
$columns = $this->filterExportColumns($columns, $requestedFields);
$csv = null;
try {
@ -1256,7 +1262,9 @@ class Pages extends Controller
fputcsv($csv, array_map(
fn (array $column): mixed => ($column['number'] ?? false)
? data_get($row, $column['field'])
: $this->csvText(data_get($row, $column['field'])),
: (($column['identifier'] ?? false)
? $this->csvIdentifier(data_get($row, $column['field']))
: $this->csvText(data_get($row, $column['field']))),
$columns,
));
}
@ -2134,6 +2142,37 @@ class Pages extends Controller
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
}
/**
* Keep client-selected exports within the fields declared by the page.
* An omitted or invalid selection falls back to the complete safe schema.
*
* @param array<int,array{label:string,field:string}> $columns
* @return array<int,array{label:string,field:string}>
*/
private function filterExportColumns(array $columns, mixed $requestedFields): array
{
if (! is_string($requestedFields) || trim($requestedFields) === '') {
return $columns;
}
$available = [];
foreach ($columns as $column) {
if ($column['field'] !== '') {
$available[$column['field']] = $column;
}
}
$selected = [];
foreach (explode(',', $requestedFields) as $field) {
$field = trim($field);
if ($field !== '' && isset($available[$field])) {
$selected[$field] = $available[$field];
}
}
return $selected !== [] ? array_values($selected) : $columns;
}
/**
* Export identifiers as text so Excel does not round or truncate long IDs.
* Keep the formula-injection guard from csvText for values beginning with a

View File

@ -342,7 +342,15 @@
}
</style>
<section class="directory-table-page" data-directory-page="{{ $page['slug'] ?? '' }}" data-directory-last-page="{{ $directoryLastPage }}" aria-label="{{ $page['title'] ?? $section['title'] }}">
<section
class="directory-table-page"
data-directory-page="{{ $page['slug'] ?? '' }}"
data-directory-last-page="{{ $directoryLastPage }}"
data-directory-user="{{ auth()->id() ?? 'guest' }}"
data-directory-section="{{ $section['slug'] ?? '' }}"
data-directory-tab="{{ $activeTab['slug'] ?? '' }}"
aria-label="{{ $page['title'] ?? $section['title'] }}"
>
<header class="directory-table-head">
<div class="directory-table-title">
<span class="material-icons-outlined" aria-hidden="true">{{ $directory['icon'] ?? 'table_view' }}</span>
@ -444,7 +452,7 @@
<label class="sr-only" for="directory-search-{{ $page['slug'] ?? 'page' }}">搜索当前表格</label>
<input id="directory-search-{{ $page['slug'] ?? 'page' }}" type="search" placeholder="搜索当前页数据..." data-directory-search>
<div class="directory-field-picker" data-directory-field-picker>
<button type="button" aria-expanded="false" aria-controls="directory-fields-{{ $page['slug'] ?? 'page' }}" data-directory-field-toggle>选择字段</button>
<button type="button" aria-expanded="false" aria-controls="directory-fields-{{ $page['slug'] ?? 'page' }}" data-directory-field-toggle>列设置</button>
<div class="directory-field-picker__panel" id="directory-fields-{{ $page['slug'] ?? 'page' }}" role="group" aria-label="选择展示字段" hidden>
@foreach ($directoryFields as $field)
<label class="directory-field-picker__option">
@ -577,7 +585,13 @@
const toggle = picker?.querySelector('[data-directory-field-toggle]');
const panel = picker?.querySelector('.directory-field-picker__panel');
const checks = [...(picker?.querySelectorAll('[data-directory-field-check]') || [])];
const storageKey = `directory-fields:${section.dataset.directoryPage || 'page'}`;
const storageKey = [
'directory-fields',
section.dataset.directoryUser || 'guest',
section.dataset.directorySection || 'section',
section.dataset.directoryPage || 'page',
section.dataset.directoryTab || 'tab',
].join(':');
const applyColumns = (save = true) => {
const selected = new Set(checks.filter((check) => check.checked).map((check) => check.value));
table.dataset.columnsReady = 'true';

View File

@ -146,6 +146,32 @@
.finance-table-controls .is-column-hidden { display: none !important; }
.finance-table-fallback-export { display: inline-flex; }
[data-finance-enhanced] .finance-table-fallback-export { display: none; }
/* Legacy business-card buttons inherit the shared VIP black-gold contract. */
.business-content-section .biz-btn-primary {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-dark, #856632);
color: var(--vip-surface, #ffffff);
}
.business-content-section .biz-btn-primary:hover {
border-color: #6a5129;
background: #6a5129;
}
.business-content-section .biz-btn-ghost {
border-color: var(--vip-border, #d8d2c7);
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
}
.business-content-section .biz-btn-ghost:hover {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-soft, #f7f0de);
}
.finance-table-field-picker {
position: relative;
}
@ -236,7 +262,15 @@
background: var(--vip-surface, #ffffff);
}
thead [data-finance-fixed="true"] { background: var(--vip-content-accent-soft, #f7f0de); }
[data-finance-fixed="end"] {
position: sticky;
right: 0;
z-index: 6;
background: var(--vip-surface, #ffffff);
}
thead [data-finance-fixed="true"],
thead [data-finance-fixed="end"] { background: var(--vip-content-accent-soft, #f7f0de); }
[data-finance-column-key].is-column-hidden { display: none !important; }
@ -343,7 +377,7 @@
cell.dataset.financeColumnKey = cell.dataset.columnKey || cell.dataset.field || cell.dataset.exportField || `${slug(cell.textContent)}_${index}`;
}
cell.dataset.financeColumnIndex = String(index);
if (index === 0 && (cell.querySelector('input[type="checkbox"]') || ['#', '序号'].includes(cell.textContent.trim()))) cell.dataset.financeFixed = 'true';
if (!cell.dataset.financeFixed && index === 0 && (cell.querySelector('input[type="checkbox"]') || ['#', '序号'].includes(cell.textContent.trim()))) cell.dataset.financeFixed = 'true';
});
const keys = headers.map((cell) => cell.dataset.financeColumnKey);
const leafRow = headerRows[headerRows.length - 1];
@ -351,7 +385,7 @@
[...leafRow.cells].forEach((cell, index) => {
cell.dataset.financeColumnKey = keys[index];
cell.dataset.financeColumnIndex = String(index);
if (headers[index]?.dataset.financeFixed === 'true') cell.dataset.financeFixed = 'true';
if (headers[index]?.dataset.financeFixed) cell.dataset.financeFixed = headers[index].dataset.financeFixed;
});
}
const groupRows = headerRows.slice(0, -1).map((row) => {
@ -371,7 +405,7 @@
if (row.classList.contains('is-empty') || row.children.length !== keys.length) return;
[...row.children].forEach((cell, index) => {
cell.dataset.financeColumnKey = cell.dataset.financeColumnKey || keys[index];
if (headers[index]?.dataset.financeFixed === 'true') cell.dataset.financeFixed = 'true';
if (headers[index]?.dataset.financeFixed) cell.dataset.financeFixed = headers[index].dataset.financeFixed;
});
});
tableStates.set(table, { headers, keys, leafRow, groupRows });
@ -428,7 +462,7 @@
const selected = new Set(checks.filter((check) => check.checked).map((check) => check.value));
table.dataset.columnsReady = 'true';
table.querySelectorAll('[data-finance-column-key]').forEach((cell) => {
if (cell.dataset.financeFixed === 'true') return;
if (cell.dataset.financeFixed === 'true' || cell.dataset.financeFixed === 'end') return;
cell.classList.toggle('is-column-hidden', !selected.has(cell.dataset.financeColumnKey));
});
const state = tableStates.get(table);
@ -440,7 +474,7 @@
const runs = [];
order.forEach((key) => {
const header = state.headers.find((cell) => cell.dataset.financeColumnKey === key);
if (!header || (!selected.has(key) && header.dataset.financeFixed !== 'true')) return;
if (!header || (!selected.has(key) && !['true', 'end'].includes(header.dataset.financeFixed))) return;
const group = String(header.dataset.financeGroup ?? '');
const previous = runs[runs.length - 1];
if (previous && previous.group === group) previous.span += 1;
@ -484,7 +518,7 @@
label.className = 'finance-table-field-picker__option';
label.draggable = true;
const key = header.dataset.financeColumnKey;
const fixed = header.dataset.financeFixed === 'true';
const fixed = Boolean(header.dataset.financeFixed);
label.dataset.financeOptionKey = key;
label.innerHTML = `<input type="checkbox" value="${key.replaceAll('"', '&quot;')}" data-finance-field-check ${fixed ? 'checked disabled' : 'checked'}><span>${header.textContent.trim() || '未命名字段'}</span>`;
panel.appendChild(label);
@ -503,10 +537,16 @@
checks = [...panel.querySelectorAll('input[data-finance-field-check], input[data-directory-field-check]')];
const defaults = headers.map((header) => header.dataset.financeColumnKey);
const fixedKeys = headers.filter((header) => header.dataset.financeFixed === 'true').map((header) => header.dataset.financeColumnKey);
const fixedEndKeys = headers.filter((header) => header.dataset.financeFixed === 'end').map((header) => header.dataset.financeColumnKey);
const defaultVisible = new Set(checks.filter((check) => check.defaultChecked || check.disabled).map((check) => check.value));
const applyOrder = (order, save = true) => {
const valid = fixedKeys.concat(order.filter((key) => defaults.includes(key) && !fixedKeys.includes(key)));
const valid = fixedKeys.concat(order.filter((key) => defaults.includes(key) && !fixedKeys.includes(key) && !fixedEndKeys.includes(key)));
defaults.forEach((key) => { if (!valid.includes(key)) valid.push(key); });
fixedEndKeys.forEach((key) => {
const currentIndex = valid.indexOf(key);
if (currentIndex >= 0) valid.splice(currentIndex, 1);
valid.push(key);
});
reorderCells(table, valid);
[...panel.querySelectorAll('[data-finance-option-key], .directory-field-picker__option')].sort((a, b) => valid.indexOf(a.dataset.financeOptionKey || a.querySelector('input')?.value) - valid.indexOf(b.dataset.financeOptionKey || b.querySelector('input')?.value)).forEach((option) => panel.insertBefore(option, panel.querySelector('.finance-table-field-picker__actions')));
if (save) {
@ -530,7 +570,7 @@
let dragging = null;
panel.querySelectorAll('[data-finance-option-key], .directory-field-picker__option').forEach((option) => {
option.dataset.financeOptionKey = option.dataset.financeOptionKey || option.querySelector('input')?.value;
if (fixedKeys.includes(option.dataset.financeOptionKey)) {
if (fixedKeys.includes(option.dataset.financeOptionKey) || fixedEndKeys.includes(option.dataset.financeOptionKey)) {
option.draggable = false;
return;
}
@ -567,6 +607,11 @@
root.insertBefore(controls, root.firstChild);
}
createPicker(table, headers, controls);
const pageScope = table.closest('[data-finance-table-surface], .upstream-data-page, .business-content-section, [data-directory-page]') || root;
pageScope.dataset.financeEnhanced = 'true';
pageScope.querySelectorAll('[data-finance-fallback-export]').forEach((fallback) => {
fallback.hidden = true;
});
if (existingDirectoryTools) {
controls.dataset.financeExportEnhanced = 'true';
const currentPage = controls.querySelector('[data-directory-export]');
@ -586,11 +631,17 @@
ensureDirectoryExport('[data-finance-export-all-fields][data-directory-export-all]', '全部数据·全部字段', true);
}
if (table.dataset.financeExportBase && !controls.querySelector('[data-finance-server-export]')) {
const disabled = table.dataset.financeExportDisabled === 'true';
const addServerExport = (label, allColumns) => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.financeServerExport = allColumns ? 'all' : 'visible';
button.textContent = label;
button.disabled = disabled;
if (disabled) {
button.setAttribute('aria-disabled', 'true');
button.title = '当前没有可导出的数据或查询索引尚未就绪';
}
controls.appendChild(button);
};
addServerExport('导出当前显示列', false);
@ -705,7 +756,7 @@
document.addEventListener('click', (event) => {
const serverButton = event.target.closest('[data-finance-server-export]');
if (serverButton) {
const table = serverButton.closest('.tb-panel, .ab-panel, .finance-table-scroll-owner, .tb-scroll, .ab-scroll')?.querySelector('table[data-finance-export-base]');
const table = serverButton.closest('[data-finance-table-surface], .upstream-data-table-panel, .directory-table-page, .tb-panel, .ab-panel, .finance-table-scroll-owner, .tb-scroll, .ab-scroll, .marketing-bills-table-wrapper, .bill-table-wrapper, .jd-wallet-table-wrap, .alibaba-bill-table-wrapper, .zhongcai-table-wrapper')?.querySelector('table[data-finance-export-base]');
if (table) {
event.preventDefault();
event.stopImmediatePropagation();

View File

@ -133,7 +133,8 @@
<span>重置</span>
</a>
<a
class="upstream-data-button {{ $rows->total() === 0 || $refundError || $refundPending || $refundIndexPending ? 'is-disabled' : '' }}"
class="upstream-data-button finance-table-fallback-export {{ $rows->total() === 0 || $refundError || $refundPending || $refundIndexPending ? 'is-disabled' : '' }}"
data-finance-fallback-export
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
>
@ -145,11 +146,17 @@
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table refund-data-table refund-data-table--wide" aria-label="售后(退货退款)">
<table
class="upstream-data-table refund-data-table refund-data-table--wide"
aria-label="售后(退货退款)"
data-finance-export-base="{{ route('business.refunds.non-jingxi-refund-data.export') }}"
data-finance-export-query="{{ e(json_encode($filters, JSON_UNESCAPED_UNICODE)) }}"
data-finance-export-disabled="{{ $rows->total() === 0 || $refundError || $refundPending || $refundIndexPending ? 'true' : 'false' }}"
>
<thead>
<tr data-header-row="labels">
@foreach ($columns as $column)
<th scope="col">{{ $column['label'] }}</th>
<th scope="col" data-export-field="{{ $column['field'] }}">{{ $column['label'] }}</th>
@endforeach
</tr>
<tr class="refund-data-table__field-row" data-header-row="fields">

View File

@ -161,7 +161,8 @@
<span>重置</span>
</a>
<a
class="upstream-data-button {{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? 'is-disabled' : '' }}"
class="upstream-data-button finance-table-fallback-export {{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? 'is-disabled' : '' }}"
data-finance-fallback-export
href="{{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? '#' : route('business.refunds.small-payment-work-orders.export', $filters) }}"
@if ($rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending) aria-disabled="true" @endif
>
@ -184,11 +185,21 @@
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table small-payment-table small-payment-table--wide" aria-label="聚水潭小额打款工单">
<table
class="upstream-data-table small-payment-table small-payment-table--wide"
aria-label="聚水潭小额打款工单"
data-finance-export-base="{{ route('business.refunds.small-payment-work-orders.export') }}"
data-finance-export-query="{{ e(json_encode($filters, JSON_UNESCAPED_UNICODE)) }}"
data-finance-export-disabled="{{ $rows->total() === 0 || $smallPaymentError || $smallPaymentPending || $smallPaymentIndexPending ? 'true' : 'false' }}"
>
<thead>
<tr>
@foreach ($columns as $column)
<th scope="col" @class(['is-number' => $column['number'] ?? false])>{{ $column['label'] }}</th>
<th
scope="col"
data-export-field="{{ $column['field'] }}"
@class(['is-number' => $column['number'] ?? false])
>{{ $column['label'] }}</th>
@endforeach
</tr>
</thead>

View File

@ -745,7 +745,7 @@
<th class="@if ($field['currency']) num @endif">{{ $field['label'] }}</th>
@endforeach
@unless ($descriptor->readOnly)
<th class="actions-cell">操作</th>
<th class="actions-cell" data-finance-fixed="end">操作</th>
@endunless
</tr>
</thead>
@ -774,7 +774,7 @@
</td>
@endforeach
@unless ($descriptor->readOnly)
<td class="actions-cell">
<td class="actions-cell" data-finance-fixed="end">
<button type="button" class="biz-btn biz-btn-ghost" title="编辑" onclick='bizOpenEditModal({{ json_encode($row->only(array_merge(['id'], array_map(fn($f) => $f['name'], $descriptor->fields)))) }})'>
<span class="material-icons-outlined" style="font-size:1.1rem">edit</span>
</button>

View File

@ -102,6 +102,8 @@ class NonJingxiRefundPageTest extends FeatureTestCase
->assertSeeText('第 2 / 2 页')
->assertSeeText('导出筛选结果')
->assertSee('workspace/refunds/non-jingxi-refund-data/export', false)
->assertSee('data-finance-export-base=', false)
->assertSee('data-export-field="refundDate"', false)
->assertSee('value="2026-08-01"', false)
->assertSee('value="ORDER-1"', false);
}
@ -171,6 +173,32 @@ class NonJingxiRefundPageTest extends FeatureTestCase
$this->assertStringContainsString('99.5', $content);
}
public function test_refund_export_accepts_only_declared_columns_in_requested_order(): 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()->andReturn([(object) [
'shop_name' => '测试店',
'refundDate' => '2026-08-20',
'sku_id' => 'SKU-1',
]]);
$this->app->instance(NonJingxiRefundMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.refunds.non-jingxi-refund-data.export', [
'columns' => 'shop_name,refundDate,not_a_real_field',
]));
$response->assertOk();
$content = $response->streamedContent();
$header = strtok($content, "\r\n");
$this->assertSame("\xEF\xBB\xBF店铺名称,退款日期", $header);
$this->assertStringContainsString('测试店,2026-08-20', $content);
$this->assertStringNotContainsString('SKU编码', $content);
}
public function test_page_does_not_report_empty_data_before_initial_sync_finishes(): void
{
$this->grantAdminPermission();

View File

@ -86,6 +86,8 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
->assertSeeText('共 26 条记录,本页 1 条')
->assertSeeText('第 2 / 2 页')
->assertSee('workspace/refunds/small-payment-work-orders/export', false)
->assertSee('data-finance-export-base=', false)
->assertSee('data-export-field="work_order_no"', false)
->assertSee('value="ORDER-001"', false);
}
@ -152,6 +154,32 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
->assertDontSeeText('当前筛选范围没有小额打款工单');
}
public function test_export_accepts_only_declared_columns_in_requested_order(): void
{
$this->grantAdminPermission();
$repository = \Mockery::mock(SmallPaymentWorkOrderMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('queryReady')->once()->andReturnTrue();
$repository->shouldReceive('exportRows')->once()->andReturn([[
'work_order_no' => '12345678901234567890',
'payment_amount' => '50.00',
'shop_name' => '京东旗舰店',
]]);
$this->app->instance(SmallPaymentWorkOrderMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route('business.refunds.small-payment-work-orders.export', [
'columns' => 'shop_name,work_order_no,not_a_real_field,shop_name',
]));
$response->assertOk();
$content = $response->streamedContent();
$header = strtok($content, "\r\n");
$this->assertSame("\xEF\xBB\xBF店铺名称,工单编号", $header);
$this->assertStringContainsString("京东旗舰店,'12345678901234567890", $content);
$this->assertStringNotContainsString('打款金额', $content);
}
public function test_page_rejects_users_without_sidebar_permission(): void
{
$subject = 'user_' . $this->user->id;