diff --git a/app/Business/WithdrawalManagementReport.php b/app/Business/WithdrawalManagementReport.php index 60e702fc..3557021b 100644 --- a/app/Business/WithdrawalManagementReport.php +++ b/app/Business/WithdrawalManagementReport.php @@ -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; diff --git a/app/Business/WithdrawalManagementSpreadsheet.php b/app/Business/WithdrawalManagementSpreadsheet.php index 0b55ab60..a510680d 100644 --- a/app/Business/WithdrawalManagementSpreadsheet.php +++ b/app/Business/WithdrawalManagementSpreadsheet.php @@ -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 $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, + * preview:list, * plan:list}> * } */ @@ -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} */ + /** @return array{0:int,1:list,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 $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>,1:array>} */ private function indexes($accounts): array { diff --git a/app/Http/Controllers/Business/WithdrawalManagement.php b/app/Http/Controllers/Business/WithdrawalManagement.php index 12cd0082..ee0588fe 100644 --- a/app/Http/Controllers/Business/WithdrawalManagement.php +++ b/app/Http/Controllers/Business/WithdrawalManagement.php @@ -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) { diff --git a/resources/views/business/pages/fund-management/withdrawal-management.blade.php b/resources/views/business/pages/fund-management/withdrawal-management.blade.php index 4a937157..1f2e31a8 100644 --- a/resources/views/business/pages/fund-management/withdrawal-management.blade.php +++ b/resources/views/business/pages/fund-management/withdrawal-management.blade.php @@ -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 @@ >
-
导入提现配置
+
导入提现金额或配置
@@ -337,7 +337,18 @@ @if ($withdrawalImportPreview['preview'] !== [])
- + + + + + + + + + + + + @foreach ($withdrawalImportPreview['preview'] as $previewRow) @@ -345,6 +356,18 @@ + + + + @endforeach @@ -377,10 +400,21 @@
- - - 下载导入模板 - +
+ 提现金额专用模板 + 按当前筛选预填店铺、余额日期、店铺余额和预留标准,只填写“提现金额”即可。 + 系统按“店铺余额 - 提现金额 ≤ 预留标准”计算是否达标;结果为“是”时按大笔提现处理。 +
+
@@ -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; } } diff --git a/routes/admin.php b/routes/admin.php index bfc967ee..d1760cd2 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Business/WithdrawalManagementImportExportTest.php b/tests/Feature/Business/WithdrawalManagementImportExportTest.php index e030f237..7f1b9b58 100644 --- a/tests/Feature/Business/WithdrawalManagementImportExportTest.php +++ b/tests/Feature/Business/WithdrawalManagementImportExportTest.php @@ -31,6 +31,18 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase private const IMPORT_HEADERS = [...self::HEADERS, '提现金额']; + private const WITHDRAWAL_TEMPLATE_HEADERS = [ + '店铺平台', + 'ERP店铺名称', + '平台店铺ID', + '余额日期', + '店铺余额', + '预留标准', + '提现金额', + '提现后余额', + '是否达标', + ]; + /** @var list */ 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> $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);
店铺平台ERP店铺名称平台店铺ID变更字段
店铺平台ERP店铺名称平台店铺ID变更字段提现金额提现后余额预留标准是否达标(大笔)
{{ $previewRow['erp_store_name'] }} {{ $previewRow['store_id'] ?: '-' }} {{ $previewRow['changes'] }}{{ isset($previewRow['withdrawal_amount']) ? number_format($previewRow['withdrawal_amount'], 2) : '-' }}{{ isset($previewRow['post_withdrawal_balance']) ? number_format($previewRow['post_withdrawal_balance'], 2) : '-' }}{{ isset($previewRow['reserve_standard']) ? number_format($previewRow['reserve_standard'], 2) : '待配置' }} + @if (($previewRow['compliance'] ?? null) === 'yes') + 是(大笔) + @elseif (($previewRow['compliance'] ?? null) === 'no') + 否 + @else + 待核验 + @endif +