feat: 提现管理增加提现金额专用导入模板 (#528)

Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
This commit is contained in:
92451 2026-10-24 21:10:46 +08:00 committed by yutu
parent 0f8ab6e5ae
commit 46b9acae2e
6 changed files with 439 additions and 32 deletions

View File

@ -126,7 +126,7 @@ class WithdrawalManagementReport
)->all();
$compliance = $rows->mapWithKeys(
fn (StoreReceivingAccount $row): array => [
(int) $row->id => $this->complianceFor(
(int) $row->id => self::complianceFor(
$balances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null],
$reserveStandards[(int) $row->id] ?? null,
$withdrawalAmounts[(int) $row->id] ?? null,
@ -350,7 +350,7 @@ class WithdrawalManagementReport
}
/** @param array{status:string,balance:?float} $balance */
private function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
public static function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
{
if (($balance['status'] ?? '') !== 'matched' || $balance['balance'] === null || $reserve === null || $withdrawalAmount === null) {
return null;

View File

@ -12,6 +12,7 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -30,6 +31,17 @@ class WithdrawalManagementSpreadsheet
];
private const CLEAR_VALUE = '[清空]';
private const WITHDRAWAL_TEMPLATE_HEADERS = [
'店铺平台',
'ERP店铺名称',
'平台店铺ID',
'余额日期',
'店铺余额',
'预留标准',
'提现金额',
'提现后余额',
'是否达标',
];
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
@ -85,10 +97,113 @@ class WithdrawalManagementSpreadsheet
return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx');
}
/**
* 按当前筛选预填店铺和余额参考值,同事只需填写提现金额。
*
* @param array<string,mixed> $report
*/
public function withdrawalTemplate(array $report): StreamedResponse
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('提现金额');
$this->writeHeader($sheet, self::WITHDRAWAL_TEMPLATE_HEADERS);
$this->sizeColumns($sheet, [14, 30, 20, 14, 18, 18, 18, 18, 14]);
$rowNumber = 2;
foreach ($report['rows'] as $row) {
$storeId = (int) $row->id;
$balanceMatch = $report['balances'][$storeId] ?? [
'status' => 'not_ready',
'balance' => null,
];
$hasBalance = ($balanceMatch['status'] ?? '') === 'matched'
&& ($balanceMatch['balance'] ?? null) !== null;
$reserve = $report['reserveStandards'][$storeId] ?? null;
$amount = $report['withdrawalAmounts'][$storeId] ?? null;
foreach ([
'A' => (string) ($row->store_platform ?? ''),
'B' => (string) ($row->erp_store_name ?? ''),
'C' => (string) ($row->store_id ?? ''),
'D' => (string) $report['balanceDate'],
] as $column => $value) {
$sheet->setCellValueExplicit("{$column}{$rowNumber}", $value, DataType::TYPE_STRING);
}
if ($hasBalance) {
$sheet->setCellValue("E{$rowNumber}", (float) $balanceMatch['balance']);
} else {
$sheet->setCellValueExplicit("E{$rowNumber}", $this->balanceStatus((string) ($balanceMatch['status'] ?? 'not_ready')), DataType::TYPE_STRING);
}
if ($reserve !== null) {
$sheet->setCellValue("F{$rowNumber}", $reserve);
} else {
$sheet->setCellValueExplicit("F{$rowNumber}", '待配置', DataType::TYPE_STRING);
}
if ($amount !== null) {
$sheet->setCellValue("G{$rowNumber}", $amount);
}
$sheet->setCellValue(
"H{$rowNumber}",
"=IF(OR(NOT(ISNUMBER(E{$rowNumber})),G{$rowNumber}=\"\"),\"\",E{$rowNumber}-G{$rowNumber})",
);
$sheet->setCellValue(
"I{$rowNumber}",
"=IF(OR(H{$rowNumber}=\"\",NOT(ISNUMBER(F{$rowNumber}))),\"\",IF(H{$rowNumber}<=F{$rowNumber},\"是\",\"否\"))",
);
$rowNumber++;
}
$lastDataRow = max(2, $rowNumber - 1);
$sheet->getStyle("E2:I{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle("E2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle("G2:G{$lastDataRow}")->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FFF7F0DE');
$sheet->getStyle('G1')->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FFB9954F');
$validation = new DataValidation();
$validation->setType(DataValidation::TYPE_DECIMAL)
->setOperator(DataValidation::OPERATOR_GREATERTHANOREQUAL)
->setFormula1('0')
->setAllowBlank(true)
->setShowErrorMessage(true)
->setErrorTitle('提现金额格式错误')
->setError('请填写非负金额,最多两位小数。');
$sheet->setDataValidation("G2:G{$lastDataRow}", $validation);
$instructions = $spreadsheet->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['提现金额导入说明'],
['1. 本模板按下载时的页面筛选生成,已预填店铺标识、余额日期、店铺余额和预留标准。'],
['2. 只需填写黄色“提现金额”列;未填写金额的店铺不会提交。填写“' . self::CLEAR_VALUE . '”可清空该店铺提现金额。'],
['3. 店铺余额、预留标准、提现后余额和是否达标仅用于下载时预览,系统会在上传后按最新余额日期重新计算。'],
['4. 判定规则:店铺余额 - 提现金额 ≤ 预留标准时,“是否达标”为“是”,即该笔提现按大笔提现处理。'],
['5. 模板余额日期为 ' . $report['balanceDate'] . ';日期滚动后请重新下载模板,避免上传过期金额。'],
], null, 'A1');
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$instructions->getColumnDimension('A')->setWidth(96);
$instructions->getStyle('A1:A6')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
foreach (range(1, 6) as $row) {
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 36);
}
$spreadsheet->setActiveSheetIndex(0);
return $this->download(
$spreadsheet,
'withdrawal_amount_import_' . str_replace('-', '', (string) $report['balanceDate']) . '.xlsx',
);
}
/**
* @return array{
* total:int,changed:int,unchanged:int,
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string}>,
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string,withdrawal_amount:?float,post_withdrawal_balance:?float,reserve_standard:?int,compliance:?string}>,
* plan:list<array{id:int,updated_at:string,changes:array<string,string|null>}>
* }
*/
@ -104,7 +219,7 @@ class WithdrawalManagementSpreadsheet
'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
]);
}
[$headerIndex, $headers] = $this->headers($matrix);
[$headerIndex, $headers, $headerMode] = $this->headers($matrix);
$indexes = array_flip($headers);
$balanceDate = $this->currentBalanceDate();
$accounts = StoreReceivingAccount::withoutGlobalScopes()
@ -112,6 +227,8 @@ class WithdrawalManagementSpreadsheet
->whereNull('deleted_at')
->get();
[$byId, $byName] = $this->indexes($accounts);
$balances = $this->shopBalances->matchesForStores($accounts, $balanceDate);
$withdrawalOnlyMode = $headerMode === 'withdrawal';
$errors = [];
$plan = [];
$preview = [];
@ -121,12 +238,15 @@ class WithdrawalManagementSpreadsheet
foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) {
$values = [];
foreach (self::CONFIG_HEADERS as $header) {
$values[$header] = trim((string) ($row[$indexes[$header]] ?? ''));
foreach ([...self::CONFIG_HEADERS, '提现金额', '余额日期'] as $header) {
$values[$header] = isset($indexes[$header])
? trim((string) ($row[$indexes[$header]] ?? ''))
: '';
}
// 专用模板会预填所有店铺,只把填写了提现金额的行视为提交数据。
if ($withdrawalOnlyMode && $values['提现金额'] === '') {
continue;
}
$values['提现金额'] = isset($indexes['提现金额'])
? trim((string) ($row[$indexes['提现金额']] ?? ''))
: '';
if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) {
continue;
}
@ -140,8 +260,13 @@ class WithdrawalManagementSpreadsheet
$erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191);
$storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191);
if ($platform === '' || $erpStoreName === '') {
$errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称";
if ($platform === '' || ($storeId === '' && $erpStoreName === '')) {
$errors[] = "第 {$line} 行缺少店铺平台或店铺标识";
continue;
}
if ($withdrawalOnlyMode && $values['余额日期'] !== '' && $values['余额日期'] !== $balanceDate) {
$errors[] = "第 {$line} 行余额日期 {$values['余额日期']} 已过期,请按最新余额日期 {$balanceDate} 重新下载模板";
continue;
}
@ -196,11 +321,40 @@ class WithdrawalManagementSpreadsheet
'changes' => $changes,
];
if (count($preview) < 20) {
$amount = array_key_exists('withdrawal_amount', $changes)
? ($changes['withdrawal_amount'] === null ? null : (float) $changes['withdrawal_amount'])
: (
$account->withdrawal_balance_date === $balanceDate
&& $account->withdrawal_amount !== null
&& is_numeric($account->withdrawal_amount)
? (float) $account->withdrawal_amount
: null
);
$balance = ($balances[(int) $account->id]['status'] ?? '') === 'matched'
? (float) $balances[(int) $account->id]['balance']
: null;
$reserve = WithdrawalReserveStandard::amountFor(
$account->store_platform,
$account->erp_store_name,
);
$preview[] = [
'platform' => $platform,
'erp_store_name' => $erpStoreName,
'store_id' => $storeId,
'changes' => implode('、', $changedLabels),
'withdrawal_amount' => $amount,
'post_withdrawal_balance' => $balance !== null && $amount !== null
? round($balance - $amount, 2)
: null,
'reserve_standard' => $reserve,
'compliance' => WithdrawalManagementReport::complianceFor(
[
'status' => $balance === null ? 'not_ready' : 'matched',
'balance' => $balance,
],
$reserve,
$amount,
),
];
}
}
@ -356,25 +510,47 @@ class WithdrawalManagementSpreadsheet
return $spreadsheet;
}
/** @return array{0:int,1:list<string>} */
/** @return array{0:int,1:list<string>,2:'config'|'withdrawal'} */
private function headers(array $matrix): array
{
foreach (array_slice($matrix, 0, 10, true) as $index => $row) {
$headers = array_map(static fn (mixed $value): string => trim((string) $value), $row);
if (in_array('店铺平台', $headers, true) && in_array('ERP店铺名称', $headers, true)) {
if (! in_array('店铺平台', $headers, true) || ! in_array('提现金额', $headers, true)) {
continue;
}
$this->requireUniqueHeader($headers, '店铺平台');
$this->requireUniqueHeader($headers, '提现金额');
$fullConfigurationTemplate = collect(self::CONFIG_HEADERS)->every(
static fn (string $header): bool => in_array($header, $headers, true),
);
if ($fullConfigurationTemplate) {
foreach (self::CONFIG_HEADERS as $required) {
if (count(array_keys($headers, $required, true)) !== 1) {
throw ValidationException::withMessages([
'import' => "导入表头必须且只能包含一个“{$required}”列,请下载最新模板。",
]);
}
}
if (count(array_keys($headers, '提现金额', true)) > 1) {
throw ValidationException::withMessages(['import' => '导入表头只能包含一个“提现金额”列。']);
$this->requireUniqueHeader($headers, $required);
}
return [(int) $index, $headers];
return [(int) $index, $headers, 'config'];
}
if (! in_array('ERP店铺名称', $headers, true) && ! in_array('平台店铺ID', $headers, true)) {
throw ValidationException::withMessages([
'import' => '提现金额模板必须包含“ERP店铺名称”或“平台店铺ID”用于定位店铺,请下载最新模板。',
]);
}
foreach ([
'ERP店铺名称', '平台店铺ID', '余额日期', '店铺余额', '预留标准',
'提现后余额', '是否达标',
] as $optional) {
if (count(array_keys($headers, $optional, true)) > 1) {
throw ValidationException::withMessages([
'import' => "导入表头只能包含一个“{$optional}”列。",
]);
}
}
return [(int) $index, $headers, 'withdrawal'];
}
throw ValidationException::withMessages([
@ -382,6 +558,16 @@ class WithdrawalManagementSpreadsheet
]);
}
/** @param list<string> $headers */
private function requireUniqueHeader(array $headers, string $header): void
{
if (count(array_keys($headers, $header, true)) !== 1) {
throw ValidationException::withMessages([
'import' => "导入表头必须且只能包含一个“{$header}”列,请下载最新模板。",
]);
}
}
/** @return array{0:array<string,list<StoreReceivingAccount>>,1:array<string,list<StoreReceivingAccount>>} */
private function indexes($accounts): array
{

View File

@ -34,6 +34,11 @@ class WithdrawalManagement extends Controller
return $this->spreadsheet->template();
}
public function withdrawalTemplate(Request $request): StreamedResponse
{
return $this->spreadsheet->withdrawalTemplate($this->report->results($request));
}
public function importPreview(Request $request): RedirectResponse
{
$file = $request->validate([
@ -93,6 +98,7 @@ class WithdrawalManagement extends Controller
'storeEntity' => 'store_entity',
'keyword' => 'keyword',
'balanceStatus' => 'balance_status',
'balanceDate' => 'balance_date',
];
foreach ($queryNames as $filterName => $queryName) {

View File

@ -37,7 +37,7 @@
['key' => 'balance_alert', 'label' => '余额预警', 'numeric' => false, 'title' => '连续3个自然日余额一致且高于预留标准'],
['key' => 'reserve_standard', 'label' => '预留标准', 'numeric' => true, 'title' => null],
['key' => 'withdrawal_amount', 'label' => '提现金额', 'numeric' => true, 'title' => null],
['key' => 'compliance', 'label' => '是否达标', 'numeric' => false, 'title' => null],
['key' => 'compliance', 'label' => '是否达标', 'numeric' => false, 'title' => '店铺余额 - 提现金额 ≤ 预留标准时判定为“是”,即按大笔提现处理'],
];
$withdrawalAlertCount = count($withdrawalAlerts);
@endphp
@ -321,7 +321,7 @@
>
<div class="biz-modal-card">
<div class="withdrawal-import__head">
<div class="biz-modal-title" id="withdrawal-import-title">导入提现配置</div>
<div class="biz-modal-title" id="withdrawal-import-title">导入提现金额或配置</div>
<button type="button" class="withdrawal-import__close" title="关闭" aria-label="关闭" data-withdrawal-import-close>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
@ -337,7 +337,18 @@
@if ($withdrawalImportPreview['preview'] !== [])
<div class="withdrawal-import__preview">
<table class="biz-table">
<thead><tr><th>店铺平台</th><th>ERP店铺名称</th><th>平台店铺ID</th><th>变更字段</th></tr></thead>
<thead>
<tr>
<th>店铺平台</th>
<th>ERP店铺名称</th>
<th>平台店铺ID</th>
<th>变更字段</th>
<th class="num">提现金额</th>
<th class="num">提现后余额</th>
<th class="num">预留标准</th>
<th>是否达标(大笔)</th>
</tr>
</thead>
<tbody>
@foreach ($withdrawalImportPreview['preview'] as $previewRow)
<tr>
@ -345,6 +356,18 @@
<td>{{ $previewRow['erp_store_name'] }}</td>
<td>{{ $previewRow['store_id'] ?: '-' }}</td>
<td>{{ $previewRow['changes'] }}</td>
<td class="num">{{ isset($previewRow['withdrawal_amount']) ? number_format($previewRow['withdrawal_amount'], 2) : '-' }}</td>
<td class="num">{{ isset($previewRow['post_withdrawal_balance']) ? number_format($previewRow['post_withdrawal_balance'], 2) : '-' }}</td>
<td class="num">{{ isset($previewRow['reserve_standard']) ? number_format($previewRow['reserve_standard'], 2) : '待配置' }}</td>
<td>
@if (($previewRow['compliance'] ?? null) === 'yes')
<span class="biz-badge biz-badge-yes">是(大笔)</span>
@elseif (($previewRow['compliance'] ?? null) === 'no')
<span class="biz-badge biz-badge-no">否</span>
@else
<span class="biz-badge biz-badge-no">待核验</span>
@endif
</td>
</tr>
@endforeach
</tbody>
@ -377,10 +400,21 @@
<label class="biz-field-label" for="withdrawal-import-file">Excel 文件</label>
<input id="withdrawal-import-file" class="biz-field-input" type="file" name="import" accept=".xlsx,.xls" required>
<div class="withdrawal-import__template">
<a class="biz-btn biz-btn-ghost" href="{{ route('business.withdrawal-management.template') }}">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>下载导入模板</span>
</a>
<div class="withdrawal-import__template-copy">
<strong>提现金额专用模板</strong>
<span>按当前筛选预填店铺、余额日期、店铺余额和预留标准,只填写“提现金额”即可。</span>
<span>系统按“店铺余额 - 提现金额 ≤ 预留标准”计算是否达标;结果为“是”时按大笔提现处理。</span>
</div>
<div class="withdrawal-import__template-actions">
<a class="biz-btn biz-btn-primary" href="{{ route('business.withdrawal-management.template.withdrawals', $withdrawalFilterQuery) }}">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>下载提现金额模板</span>
</a>
<a class="biz-btn biz-btn-ghost" href="{{ route('business.withdrawal-management.template') }}">
<span class="material-icons-outlined" aria-hidden="true">settings</span>
<span>下载完整配置模板</span>
</a>
</div>
</div>
<div class="biz-modal-foot">
<button type="button" class="biz-btn biz-btn-ghost" data-withdrawal-import-close>取消</button>
@ -443,9 +477,12 @@
.withdrawal-import__stats span { color:#6b665e; font-size:.8125rem; }
.withdrawal-import__stats strong { color:#171717; font-variant-numeric:tabular-nums; }
.withdrawal-import__preview { max-height:360px; overflow:auto; border:1px solid #e5e0d7; }
.withdrawal-import__preview .biz-table { min-width:700px; }
.withdrawal-import__preview .biz-table { min-width:980px; }
.withdrawal-import__status { margin:.75rem 0 0; color:#6b665e; font-size:.8125rem; }
.withdrawal-import__template { margin-top:1rem; }
.withdrawal-import__template { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:1rem; align-items:center; margin-top:1rem; padding:.85rem 1rem; border:1px solid #d8d2c7; border-radius:8px; background:#f8f6f1; }
.withdrawal-import__template-copy { display:flex; min-width:0; flex-direction:column; gap:.25rem; color:#595959; font-size:.75rem; line-height:1.55; }
.withdrawal-import__template-copy strong { color:#171717; font-size:.8125rem; }
.withdrawal-import__template-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:.5rem; }
.withdrawal-import__empty, .withdrawal-import__error { padding:.85rem 1rem; border:1px solid #d8d2c7; color:#595959; }
.withdrawal-import__error { margin-bottom:1rem; border-color:#d6a4a4; background:#fff7f7; color:#8a1f1f; }
@media (max-width: 640px) {
@ -456,6 +493,9 @@
.withdrawal-import-modal { padding:1rem; }
.withdrawal-import__stats { grid-template-columns:1fr; }
.withdrawal-import__stats > div + div { border-left:0; border-top:1px solid #d8d2c7; }
.withdrawal-import__template { grid-template-columns:1fr; }
.withdrawal-import__template-actions { justify-content:stretch; }
.withdrawal-import__template-actions .biz-btn { flex:1 1 100%; justify-content:center; }
.withdrawal-import-modal .biz-modal-foot { align-items:stretch; flex-direction:column-reverse; }
.withdrawal-import-modal .biz-btn { justify-content:center; }
}

View File

@ -625,6 +625,7 @@ Route::prefix('workspace/fund-management/withdrawal-management')
->group(function () {
Route::get('export', [WithdrawalManagement::class, 'export'])->name('export');
Route::get('template', [WithdrawalManagement::class, 'template'])->name('template');
Route::get('template/withdrawals', [WithdrawalManagement::class, 'withdrawalTemplate'])->name('template.withdrawals');
Route::post('import/preview', [WithdrawalManagement::class, 'importPreview'])
->middleware('import')
->name('import.preview');

View File

@ -31,6 +31,18 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
private const IMPORT_HEADERS = [...self::HEADERS, '提现金额'];
private const WITHDRAWAL_TEMPLATE_HEADERS = [
'店铺平台',
'ERP店铺名称',
'平台店铺ID',
'余额日期',
'店铺余额',
'预留标准',
'提现金额',
'提现后余额',
'是否达标',
];
/** @var list<string> */
private array $temporaryFiles = [];
@ -68,7 +80,10 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
->assertSee('data-withdrawal-sort-asc', false)
->assertSee('data-withdrawal-sort-desc', false)
->assertSee(route('business.withdrawal-management.export', ['platform' => '天猫']), false)
->assertSee(route('business.withdrawal-management.template'), false);
->assertSee(route('business.withdrawal-management.template'), false)
->assertSee(route('business.withdrawal-management.template.withdrawals', ['platform' => '天猫']), false)
->assertSeeText('下载提现金额模板')
->assertSeeText('按大笔提现处理');
}
public function test_page_sorts_all_filtered_rows_before_pagination(): void
@ -180,6 +195,142 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
}
}
public function test_withdrawal_amount_template_prefills_current_filtered_store_and_balance(): void
{
Carbon::setTestNow('2026-09-24 09:00:00');
try {
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '天猫',
'erp_store_name' => '金额模板目标店',
'store_id' => 'TM-AMOUNT-TEMPLATE',
'store_entity_name' => '模板测试主体',
]);
$this->insertBalanceSnapshot('2026-09-23', '2026-09-24 08:00:00', '金额模板目标店', 25000);
$response = $this->loginAs()->get(route('business.withdrawal-management.template.withdrawals', [
'platform' => '天猫',
'keyword' => '金额模板目标店',
]));
$response->assertOk()->assertDownload();
$path = $this->temporaryPath('withdrawal-amount-template-');
file_put_contents($path, $response->streamedContent());
$spreadsheet = IOFactory::load($path);
try {
$sheet = $spreadsheet->getSheet(0);
$this->assertSame('提现金额', $sheet->getTitle());
$this->assertSame(self::WITHDRAWAL_TEMPLATE_HEADERS, $sheet->rangeToArray('A1:I1')[0]);
$this->assertSame('天猫', $sheet->getCell('A2')->getValue());
$this->assertSame('金额模板目标店', $sheet->getCell('B2')->getValue());
$this->assertSame('TM-AMOUNT-TEMPLATE', $sheet->getCell('C2')->getValue());
$this->assertSame('2026-09-23', $sheet->getCell('D2')->getValue());
$this->assertSame(25000.0, (float) $sheet->getCell('E2')->getValue());
$this->assertSame(20000.0, (float) $sheet->getCell('F2')->getValue());
$this->assertNull($sheet->getCell('G2')->getValue());
$this->assertStringContainsString('E2-G2', (string) $sheet->getCell('H2')->getValue());
$this->assertStringContainsString('H2<=F2', (string) $sheet->getCell('I2')->getValue());
$this->assertSame(2, $sheet->getHighestDataRow());
$this->assertStringContainsString('大笔提现', (string) $spreadsheet->getSheetByName('填写说明')?->getCell('A5')->getValue());
} finally {
$spreadsheet->disconnectWorksheets();
}
} finally {
Carbon::setTestNow();
}
}
public function test_withdrawal_amount_template_imports_only_filled_amounts_and_previews_compliance(): void
{
Carbon::setTestNow('2026-09-24 09:00:00');
try {
$qualified = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '天猫',
'erp_store_name' => '大笔达标店',
'store_id' => 'TM-LARGE-YES',
'store_entity_name' => '模板测试主体',
]);
$unqualified = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '天猫',
'erp_store_name' => '大笔未达标店',
'store_id' => 'TM-LARGE-NO',
'store_entity_name' => '模板测试主体',
]);
$this->insertBalanceSnapshot('2026-09-23', '2026-09-24 08:00:00', '大笔达标店', 25000);
$this->insertBalanceSnapshot('2026-09-23', '2026-09-24 08:01:00', '大笔未达标店', 25000);
$this->loginAs();
$previewResponse = $this->post(route('business.withdrawal-management.import.preview'), [
'import' => $this->withdrawalWorkbook([
['天猫', '大笔达标店', 'TM-LARGE-YES', '2026-09-23', 25000, 20000, '5000.00'],
['天猫', '大笔未达标店', 'TM-LARGE-NO', '2026-09-23', 25000, 20000, '4999.99'],
]),
]);
$previewResponse->assertSessionHas('withdrawalImportPreview', function (array $preview): bool {
return $preview['total'] === 2
&& $preview['changed'] === 2
&& $preview['preview'][0]['withdrawal_amount'] === 5000.0
&& $preview['preview'][0]['post_withdrawal_balance'] === 20000.0
&& $preview['preview'][0]['compliance'] === 'yes'
&& $preview['preview'][1]['withdrawal_amount'] === 4999.99
&& $preview['preview'][1]['post_withdrawal_balance'] === 20000.01
&& $preview['preview'][1]['compliance'] === 'no';
});
$preview = $previewResponse->getSession()->get('withdrawalImportPreview');
$this->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'withdrawal-management',
]))
->assertOk()
->assertSeeText('提现后余额')
->assertSeeText('是否达标(大笔)')
->assertSeeText('是(大笔)');
$this->post(route('business.withdrawal-management.import.commit'), [
'preview_token' => $preview['token'],
])->assertSessionHasNoErrors();
$this->assertSame('5000.00', $qualified->fresh()->withdrawal_amount);
$this->assertSame('2026-09-23', $qualified->fresh()->withdrawal_balance_date);
$this->assertSame('4999.99', $unqualified->fresh()->withdrawal_amount);
$this->assertSame('2026-09-23', $unqualified->fresh()->withdrawal_balance_date);
} finally {
Carbon::setTestNow();
}
}
public function test_withdrawal_amount_template_rejects_a_stale_balance_date(): void
{
Carbon::setTestNow('2026-09-24 09:00:00');
try {
$store = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '京东',
'erp_store_name' => '过期金额模板店',
'store_id' => 'JD-STALE-AMOUNT',
'store_entity_name' => '模板测试主体',
]);
$this->loginAs();
$this->withExceptionHandling()
->post(route('business.withdrawal-management.import.preview'), [
'import' => $this->withdrawalWorkbook([
['京东', '过期金额模板店', 'JD-STALE-AMOUNT', '2026-09-22', 10000, 5000, '5000.00'],
]),
])
->assertSessionHasErrors('import');
$this->assertNull($store->fresh()->withdrawal_amount);
} finally {
Carbon::setTestNow();
}
}
public function test_valid_import_is_previewed_before_allowed_fields_are_committed(): void
{
$store = StoreReceivingAccount::query()->create([
@ -554,6 +705,29 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
);
}
/** @param list<list<mixed>> $rows */
private function withdrawalWorkbook(array $rows): UploadedFile
{
$path = $this->temporaryPath('withdrawal-amount-import-');
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('提现金额');
$sheet->fromArray(self::WITHDRAWAL_TEMPLATE_HEADERS, null, 'A1');
if ($rows !== []) {
$sheet->fromArray($rows, null, 'A2');
}
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
return new UploadedFile(
$path,
'提现金额.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
null,
true,
);
}
private function temporaryPath(string $prefix): string
{
$path = tempnam(sys_get_temp_dir(), $prefix);