From a50d14fc2a7ff257ff98ffd6ef511c074ef31056 Mon Sep 17 00:00:00 2001
From: 92451 <924518135@qq.com>
Date: Wed, 23 Sep 2026 15:05:30 +0800
Subject: [PATCH 1/2] feat: add withdrawal store entity filter
---
app/Http/Controllers/Business/Pages.php | 17 ++++++++++++++++-
.../withdrawal-management.blade.php | 11 ++++++++++-
tests/Feature/Business/ManagementCrudTest.php | 16 ++++++++++++++++
3 files changed, 42 insertions(+), 2 deletions(-)
diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php
index 6c93ab1f..b6c80bde 100644
--- a/app/Http/Controllers/Business/Pages.php
+++ b/app/Http/Controllers/Business/Pages.php
@@ -875,6 +875,7 @@ class Pages extends Controller
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$platform = $clean(request()->query('platform'));
$withdrawalPath = $clean(request()->query('withdrawal_path'));
+ $storeEntity = $clean(request()->query('store_entity'), 191);
$keyword = $clean(request()->query('keyword'), 191);
$balanceStatus = $clean(request()->query('balance_status'));
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
@@ -897,6 +898,15 @@ class Pages extends Controller
->filter()
->values()
->all();
+ $storeEntities = StoreReceivingAccount::query()
+ ->whereNotNull('store_entity_name')
+ ->where('store_entity_name', '<>', '')
+ ->distinct()
+ ->orderBy('store_entity_name')
+ ->pluck('store_entity_name')
+ ->filter()
+ ->values()
+ ->all();
$query = StoreReceivingAccount::query();
@@ -908,6 +918,10 @@ class Pages extends Controller
$query->where('receiving_method', $withdrawalPath);
}
+ if ($storeEntity !== '') {
+ $query->where('store_entity_name', $storeEntity);
+ }
+
if ($keyword !== '') {
$query->where('store_name', 'like', '%' . addcslashes($keyword, '%_\\') . '%');
}
@@ -963,9 +977,10 @@ class Pages extends Controller
return [
'view' => 'business.pages.fund-management.withdrawal-management',
'withdrawalRows' => $withdrawalRows,
- 'withdrawalFilters' => compact('platform', 'withdrawalPath', 'keyword', 'balanceStatus'),
+ 'withdrawalFilters' => compact('platform', 'withdrawalPath', 'storeEntity', 'keyword', 'balanceStatus'),
'withdrawalPlatforms' => $platforms,
'withdrawalPaths' => $withdrawalPaths,
+ 'withdrawalStoreEntities' => $storeEntities,
'withdrawalBalances' => $withdrawalBalances,
'withdrawalReserveStandards' => $withdrawalReserveStandards,
'withdrawalBalanceSummary' => [
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 773c9b7f..507fdfdb 100644
--- a/resources/views/business/pages/fund-management/withdrawal-management.blade.php
+++ b/resources/views/business/pages/fund-management/withdrawal-management.blade.php
@@ -32,6 +32,15 @@
@endforeach
+
+
+
+
@@ -47,7 +56,7 @@
- @if (request()->hasAny(['platform', 'withdrawal_path', 'keyword', 'balance_status']))
+ @if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status']))
重置
@endif
diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php
index 89dbdc5b..3d6a19bb 100644
--- a/tests/Feature/Business/ManagementCrudTest.php
+++ b/tests/Feature/Business/ManagementCrudTest.php
@@ -112,6 +112,11 @@ class ManagementCrudTest extends FeatureTestCase
'page' => 'withdrawal-management',
'balance_status' => 'unmatched',
]));
+ $entityResponse = $this->loginAs()->get(route('business.pages.show', [
+ 'section' => 'fund-management',
+ 'page' => 'withdrawal-management',
+ 'store_entity' => '提现测试主体',
+ ]));
} finally {
Carbon::setTestNow();
}
@@ -130,6 +135,7 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('余额源 1 条')
->assertSeeText('已显示余额 1 条')
->assertSeeText('余额状态')
+ ->assertSeeText('店铺主体')
->assertSeeText('预留标准')
->assertSeeText('20,000.00')
->assertSeeText('5,000.00')
@@ -151,6 +157,8 @@ class ManagementCrudTest extends FeatureTestCase
'是否达标 | ',
], false)
->assertSee('aria-label="提现管理筛选"', false)
+ ->assertSee('name="store_entity"', false)
+ ->assertSee('value="提现测试主体"', false)
->assertSee('title="聚水潭店铺:天猫-提现测试店铺;余额日期:2026-09-12"', false)
->assertSeeInOrder(['提现测试ERP店', '未接入测试ERP店'])
->assertDontSee('店铺名称 | ', false)
@@ -163,6 +171,14 @@ class ManagementCrudTest extends FeatureTestCase
->assertOk()
->assertSeeText('未接入测试ERP店')
->assertDontSeeText('提现测试ERP店');
+
+ $entityResponse
+ ->assertOk()
+ ->assertSeeText('提现测试ERP店')
+ ->assertSeeText('提现测试主体')
+ ->assertDontSeeText('未接入测试ERP店')
+ ->assertSee('name="store_entity"', false)
+ ->assertSee('value="提现测试主体" selected', false);
}
public function testInstallationDetailsRendersWorkbookHeadersAndImportedRows(): void
--
2.47.2
From 2f899ece7a4c7d4d03730164f8103f29f321fb56 Mon Sep 17 00:00:00 2001
From: 92451 <924518135@qq.com>
Date: Wed, 23 Sep 2026 20:00:12 +0800
Subject: [PATCH 2/2] feat: add withdrawal import and export
---
app/Business/WithdrawalManagementReport.php | 134 ++++++
.../WithdrawalManagementSpreadsheet.php | 422 ++++++++++++++++++
app/Http/Controllers/Business/Pages.php | 124 +----
.../Business/WithdrawalManagement.php | 106 +++++
.../withdrawal-management.blade.php | 153 ++++++-
routes/admin.php | 14 +
.../WithdrawalManagementImportExportTest.php | 318 +++++++++++++
7 files changed, 1159 insertions(+), 112 deletions(-)
create mode 100644 app/Business/WithdrawalManagementReport.php
create mode 100644 app/Business/WithdrawalManagementSpreadsheet.php
create mode 100644 app/Http/Controllers/Business/WithdrawalManagement.php
create mode 100644 tests/Feature/Business/WithdrawalManagementImportExportTest.php
diff --git a/app/Business/WithdrawalManagementReport.php b/app/Business/WithdrawalManagementReport.php
new file mode 100644
index 00000000..4519e6a4
--- /dev/null
+++ b/app/Business/WithdrawalManagementReport.php
@@ -0,0 +1,134 @@
+,paths:list,storeEntities:list,rows:Collection,
+ * balances:array,
+ * reserveStandards:array,summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
+ * balanceColumnLabel:string,balanceDate:string
+ * }
+ */
+ public function results(Request $request): array
+ {
+ $filters = $this->filters($request);
+ $platforms = $this->options('store_platform');
+ $paths = $this->options('receiving_method');
+ $storeEntities = $this->options('store_entity_name');
+ $query = StoreReceivingAccount::query();
+
+ if ($filters['platform'] !== '') {
+ $query->where('store_platform', $filters['platform']);
+ }
+ if ($filters['withdrawalPath'] !== '') {
+ $query->where('receiving_method', $filters['withdrawalPath']);
+ }
+ if ($filters['storeEntity'] !== '') {
+ $query->where('store_entity_name', $filters['storeEntity']);
+ }
+ if ($filters['keyword'] !== '') {
+ $query->where('store_name', 'like', '%' . addcslashes($filters['keyword'], '%_\\') . '%');
+ }
+
+ $today = now()->startOfDay();
+ $balanceDate = $today->copy()->subDay()->toDateString();
+ $rows = $query->orderByDesc('id')->get();
+ $balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
+ $balanceCounts = collect($balances)->countBy(
+ static fn (array $match): string => $match['status'],
+ );
+
+ if ($filters['balanceStatus'] !== '') {
+ $rows = $rows->filter(
+ static fn (StoreReceivingAccount $row): bool => ($balances[(int) $row->id]['status'] ?? 'not_ready') === $filters['balanceStatus'],
+ );
+ }
+
+ $statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3];
+ $rows = $rows->sort(
+ static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($balances, $statusOrder): int {
+ $leftStatus = $balances[(int) $left->id]['status'] ?? 'not_ready';
+ $rightStatus = $balances[(int) $right->id]['status'] ?? 'not_ready';
+ $statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99);
+
+ return $statusComparison !== 0
+ ? $statusComparison
+ : ((int) $right->id <=> (int) $left->id);
+ },
+ )->values();
+ $reserveStandards = $rows->mapWithKeys(
+ static fn (StoreReceivingAccount $row): array => [
+ (int) $row->id => WithdrawalReserveStandard::amountFor(
+ $row->store_platform,
+ $row->erp_store_name,
+ ),
+ ],
+ )->all();
+
+ return [
+ 'filters' => $filters,
+ 'platforms' => $platforms,
+ 'paths' => $paths,
+ 'storeEntities' => $storeEntities,
+ 'rows' => $rows,
+ 'balances' => $balances,
+ 'reserveStandards' => $reserveStandards,
+ 'summary' => [
+ 'source' => $this->shopBalances->countForDate($balanceDate),
+ 'matched' => (int) $balanceCounts->get('matched', 0),
+ 'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
+ 'unmatched' => (int) $balanceCounts->get('unmatched', 0),
+ 'not_ready' => (int) $balanceCounts->get('not_ready', 0),
+ ],
+ 'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
+ 'balanceDate' => $balanceDate,
+ ];
+ }
+
+ /** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string} */
+ public function filters(Request $request): array
+ {
+ $clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
+ $balanceStatus = $clean($request->query('balance_status'));
+
+ if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
+ $balanceStatus = '';
+ }
+
+ return [
+ 'platform' => $clean($request->query('platform')),
+ 'withdrawalPath' => $clean($request->query('withdrawal_path')),
+ 'storeEntity' => $clean($request->query('store_entity'), 191),
+ 'keyword' => $clean($request->query('keyword'), 191),
+ 'balanceStatus' => $balanceStatus,
+ ];
+ }
+
+ /** @return list */
+ private function options(string $column): array
+ {
+ return StoreReceivingAccount::query()
+ ->whereNotNull($column)
+ ->where($column, '<>', '')
+ ->distinct()
+ ->orderBy($column)
+ ->pluck($column)
+ ->filter()
+ ->values()
+ ->all();
+ }
+}
diff --git a/app/Business/WithdrawalManagementSpreadsheet.php b/app/Business/WithdrawalManagementSpreadsheet.php
new file mode 100644
index 00000000..0b3c12a8
--- /dev/null
+++ b/app/Business/WithdrawalManagementSpreadsheet.php
@@ -0,0 +1,422 @@
+,
+ * balances:array,
+ * reserveStandards:array,balanceColumnLabel:string,balanceDate:string
+ * } $report
+ */
+ public function export(array $report): StreamedResponse
+ {
+ $spreadsheet = $this->workbook($report);
+
+ return $this->download(
+ $spreadsheet,
+ 'withdrawal_management_' . str_replace('-', '', $report['balanceDate']) . '_' . now()->format('His') . '.xlsx',
+ );
+ }
+
+ public function template(): StreamedResponse
+ {
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->setTitle('提现管理');
+ $this->writeHeader($sheet, self::CONFIG_HEADERS);
+ $this->sizeColumns($sheet, [14, 30, 20, 32, 16, 24, 22]);
+
+ $instructions = $spreadsheet->createSheet();
+ $instructions->setTitle('填写说明');
+ $instructions->fromArray([
+ ['提现管理导入说明'],
+ ['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'],
+ ['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位为可更新字段。'],
+ ['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'],
+ ['4. 银行卡号只填写后四位数字。上传后会先展示预览,确认后才写入。'],
+ ], null, 'A1');
+ $instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
+ $instructions->getColumnDimension('A')->setWidth(88);
+ $instructions->getStyle('A1:A5')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+ foreach (range(1, 5) as $row) {
+ $instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 34);
+ }
+ $spreadsheet->setActiveSheetIndex(0);
+
+ return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx');
+ }
+
+ /**
+ * @return array{
+ * total:int,changed:int,unchanged:int,
+ * preview:list,
+ * plan:list}>
+ * }
+ */
+ public function preview(UploadedFile $file, int $companyId): array
+ {
+ try {
+ $spreadsheet = IOFactory::load($file->getRealPath());
+ $sheet = $spreadsheet->getSheetByName('提现管理') ?? $spreadsheet->getActiveSheet();
+ $matrix = $sheet->toArray(null, false, true, false);
+ $spreadsheet->disconnectWorksheets();
+ } catch (Throwable) {
+ throw ValidationException::withMessages([
+ 'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
+ ]);
+ }
+ [$headerIndex, $headers] = $this->headers($matrix);
+ $indexes = array_flip($headers);
+ $accounts = StoreReceivingAccount::withoutGlobalScopes()
+ ->where('company_id', $companyId)
+ ->whereNull('deleted_at')
+ ->get();
+ [$byId, $byName] = $this->indexes($accounts);
+ $errors = [];
+ $plan = [];
+ $preview = [];
+ $seen = [];
+ $total = 0;
+ $unchanged = 0;
+
+ 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]] ?? ''));
+ }
+ if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) {
+ continue;
+ }
+
+ $total++;
+ if ($total > 5000) {
+ throw ValidationException::withMessages(['import' => '单次最多导入 5000 行,请拆分文件后重试。']);
+ }
+ $line = $matrixIndex + 1;
+ $platform = mb_substr($values['店铺平台'], 0, 80);
+ $erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191);
+ $storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191);
+
+ if ($platform === '' || $erpStoreName === '') {
+ $errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称";
+ continue;
+ }
+
+ $matches = $storeId !== ''
+ ? ($byId[$this->key($platform, $storeId)] ?? [])
+ : ($byName[$this->key($platform, $erpStoreName)] ?? []);
+ if (count($matches) === 0) {
+ $identity = $storeId !== '' ? "平台店铺ID {$storeId}" : "ERP 店铺名称 {$erpStoreName}";
+ $errors[] = "第 {$line} 行未找到 {$platform} / {$identity}";
+ continue;
+ }
+ if (count($matches) > 1) {
+ $errors[] = "第 {$line} 行匹配到多家店铺,请补充或核对平台店铺ID";
+ continue;
+ }
+
+ /** @var StoreReceivingAccount $account */
+ $account = $matches[0];
+ if (isset($seen[(int) $account->id])) {
+ $errors[] = "第 {$line} 行与第 {$seen[(int) $account->id]} 行重复指向同一家店铺";
+ continue;
+ }
+ $seen[(int) $account->id] = $line;
+
+ try {
+ $changes = $this->changes($account, $values);
+ } catch (\InvalidArgumentException $exception) {
+ $errors[] = "第 {$line} 行" . $exception->getMessage();
+ continue;
+ }
+
+ if ($changes === []) {
+ $unchanged++;
+ continue;
+ }
+
+ $labels = [
+ 'store_entity_name' => '店铺主体',
+ 'receiving_method' => '提现路径',
+ 'receiving_account_name' => '提现账户名称',
+ 'bank_card_last_four' => '银行卡号后四位',
+ ];
+ $changedLabels = array_map(
+ static fn (string $field): string => $labels[$field],
+ array_keys($changes),
+ );
+ $plan[] = [
+ 'id' => (int) $account->id,
+ 'updated_at' => (string) $account->getRawOriginal('updated_at'),
+ 'changes' => $changes,
+ ];
+ if (count($preview) < 20) {
+ $preview[] = [
+ 'platform' => $platform,
+ 'erp_store_name' => $erpStoreName,
+ 'store_id' => $storeId,
+ 'changes' => implode('、', $changedLabels),
+ ];
+ }
+ }
+
+ if ($total === 0) {
+ throw ValidationException::withMessages(['import' => '导入文件中没有可识别的数据行。']);
+ }
+ if ($errors !== []) {
+ $visible = array_slice($errors, 0, 10);
+ if (count($errors) > count($visible)) {
+ $visible[] = '另有 ' . (count($errors) - count($visible)) . ' 项错误';
+ }
+ throw ValidationException::withMessages(['import' => implode(';', $visible) . '。']);
+ }
+
+ return [
+ 'total' => $total,
+ 'changed' => count($plan),
+ 'unchanged' => $unchanged,
+ 'preview' => $preview,
+ 'plan' => $plan,
+ ];
+ }
+
+ /** @param list}> $plan */
+ public function commit(array $plan, int $companyId): int
+ {
+ return DB::transaction(function () use ($plan, $companyId): int {
+ $updated = 0;
+
+ foreach ($plan as $item) {
+ /** @var StoreReceivingAccount|null $account */
+ $account = StoreReceivingAccount::withoutGlobalScopes()
+ ->where('company_id', $companyId)
+ ->whereNull('deleted_at')
+ ->whereKey((int) $item['id'])
+ ->lockForUpdate()
+ ->first();
+
+ if ($account === null || (string) $account->getRawOriginal('updated_at') !== (string) $item['updated_at']) {
+ throw ValidationException::withMessages([
+ 'import' => '预览后店铺配置发生变化,请重新上传并预览。',
+ ]);
+ }
+
+ foreach ($item['changes'] as $field => $value) {
+ if ($field === 'bank_card_last_four') {
+ $account->bank_card_last_four = $value;
+ } else {
+ $account->setAttribute($field, $value);
+ }
+ }
+ $account->save();
+ $updated++;
+ }
+
+ return $updated;
+ });
+ }
+
+ private function workbook(array $report): Spreadsheet
+ {
+ $headers = [...self::CONFIG_HEADERS, $report['balanceColumnLabel'], '预留标准', '提现金额', '是否达标'];
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->setTitle('提现管理');
+ $this->writeHeader($sheet, $headers);
+ $this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 18, 18, 14]);
+
+ $rowNumber = 2;
+ foreach ($report['rows'] as $row) {
+ $match = $report['balances'][(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null];
+ $reserve = $report['reserveStandards'][(int) $row->id] ?? null;
+ $textValues = [
+ 1 => (string) ($row->store_platform ?? ''),
+ 2 => (string) ($row->erp_store_name ?? ''),
+ 3 => (string) ($row->store_id ?? ''),
+ 4 => (string) ($row->store_entity_name ?? ''),
+ 5 => (string) ($row->withdrawal_path ?? ''),
+ 6 => (string) ($row->withdrawal_account_name ?? ''),
+ 7 => (string) ($row->bank_card_last_four ?? ''),
+ ];
+ foreach ($textValues as $column => $value) {
+ $sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING);
+ }
+
+ if ($match['status'] === 'matched') {
+ $sheet->setCellValue([8, $rowNumber], (float) $match['balance']);
+ } else {
+ $sheet->setCellValueExplicit([8, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
+ }
+ if ($reserve === null) {
+ $sheet->setCellValueExplicit([9, $rowNumber], '待配置', DataType::TYPE_STRING);
+ } else {
+ $sheet->setCellValue([9, $rowNumber], $reserve);
+ }
+ $sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING);
+ $sheet->setCellValueExplicit([11, $rowNumber], '待核验', DataType::TYPE_STRING);
+ $rowNumber++;
+ }
+
+ if ($rowNumber > 2) {
+ $sheet->getStyle('H2:I' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
+ $sheet->getStyle('H2:I' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
+ }
+ $sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
+ $sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
+ $spreadsheet->getProperties()->setTitle('提现管理')->setDescription('余额数据日期:' . $report['balanceDate']);
+
+ return $spreadsheet;
+ }
+
+ /** @return array{0:int,1:list} */
+ 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)) {
+ foreach (self::CONFIG_HEADERS as $required) {
+ if (count(array_keys($headers, $required, true)) !== 1) {
+ throw ValidationException::withMessages([
+ 'import' => "导入表头必须且只能包含一个“{$required}”列,请下载最新模板。",
+ ]);
+ }
+ }
+
+ return [(int) $index, $headers];
+ }
+ }
+
+ throw ValidationException::withMessages([
+ 'import' => '未找到提现管理导入表头,请下载最新模板。',
+ ]);
+ }
+
+ /** @return array{0:array>,1:array>} */
+ private function indexes($accounts): array
+ {
+ $byId = [];
+ $byName = [];
+
+ foreach ($accounts as $account) {
+ $platform = trim((string) $account->store_platform);
+ $storeId = trim((string) ($account->store_id ?: $account->wallet_number));
+ $erpStoreName = trim((string) $account->erp_store_name);
+ if ($platform !== '' && $storeId !== '') {
+ $byId[$this->key($platform, $storeId)][] = $account;
+ }
+ if ($platform !== '' && $erpStoreName !== '') {
+ $byName[$this->key($platform, $erpStoreName)][] = $account;
+ }
+ }
+
+ return [$byId, $byName];
+ }
+
+ /** @param array $values @return array */
+ private function changes(StoreReceivingAccount $account, array $values): array
+ {
+ $fields = [
+ '店铺主体' => ['field' => 'store_entity_name', 'current' => $account->store_entity_name, 'max' => 191, 'clear' => ''],
+ '提现路径' => ['field' => 'receiving_method', 'current' => $account->withdrawal_path, 'max' => 191, 'clear' => ''],
+ '提现账户名称' => ['field' => 'receiving_account_name', 'current' => $account->withdrawal_account_name, 'max' => 191, 'clear' => null],
+ '银行卡号(后四位)' => ['field' => 'bank_card_last_four', 'current' => $account->bank_card_last_four, 'max' => 4, 'clear' => null],
+ ];
+ $changes = [];
+
+ foreach ($fields as $header => $definition) {
+ $raw = trim((string) ($values[$header] ?? ''));
+ if ($raw === '') {
+ continue;
+ }
+ $value = $raw === self::CLEAR_VALUE ? $definition['clear'] : $raw;
+ if ($header === '银行卡号(后四位)' && $value !== null) {
+ if (preg_match('/^\d{4}$/', $value) !== 1) {
+ throw new \InvalidArgumentException('的银行卡号必须填写后四位数字');
+ }
+ }
+ if ($value !== null && mb_strlen($value) > $definition['max']) {
+ throw new \InvalidArgumentException("的{$header}超过 {$definition['max']} 个字符");
+ }
+ $current = trim((string) ($definition['current'] ?? '')) ?: null;
+ $comparisonValue = $value === '' ? null : $value;
+ if ($current !== $comparisonValue) {
+ $changes[$definition['field']] = $value;
+ }
+ }
+
+ return $changes;
+ }
+
+ private function key(string $platform, string $identity): string
+ {
+ return trim($platform) . "\0" . trim($identity);
+ }
+
+ private function balanceStatus(string $status): string
+ {
+ return match ($status) {
+ 'ambiguous' => '多条匹配',
+ 'unmatched' => '未匹配',
+ default => '待同步',
+ };
+ }
+
+ private function writeHeader($sheet, array $headers): void
+ {
+ $lastColumn = Coordinate::stringFromColumnIndex(count($headers));
+ $sheet->fromArray($headers, null, 'A1');
+ $sheet->freezePane('A2');
+ $sheet->setAutoFilter("A1:{$lastColumn}1");
+ $sheet->getRowDimension(1)->setRowHeight(26);
+ $sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
+ $sheet->getStyle("A1:{$lastColumn}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF856632');
+ $sheet->getStyle("A1:{$lastColumn}1")->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+ }
+
+ private function sizeColumns($sheet, array $widths): void
+ {
+ foreach ($widths as $index => $width) {
+ $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
+ }
+ }
+
+ private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
+ {
+ return response()->streamDownload(function () use ($spreadsheet): void {
+ (new Xlsx($spreadsheet))->save('php://output');
+ $spreadsheet->disconnectWorksheets();
+ }, $filename, [
+ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Cache-Control' => 'private, no-store',
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php
index b6c80bde..35d97e88 100644
--- a/app/Http/Controllers/Business/Pages.php
+++ b/app/Http/Controllers/Business/Pages.php
@@ -10,14 +10,13 @@ use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountTemplate;
use App\Business\StoreReceivingAccountWorkbookImporter;
-use App\Business\WithdrawalReserveStandard;
+use App\Business\WithdrawalManagementReport;
use App\Models\Banking\MarketingAccountBill;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail;
-use App\Models\Business\StoreReceivingAccount;
use App\Business\CourierSurchargeAuditLogRepository;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeTypeRepository;
@@ -43,7 +42,6 @@ use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
-use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
@@ -93,7 +91,7 @@ class Pages extends Controller
private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes,
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits,
- private readonly JushuitanShopBalanceRepository $jushuitanShopBalances,
+ private readonly WithdrawalManagementReport $withdrawalManagementReport,
private readonly ShippingOrderRepository $shippingOrders,
) {
$this->middleware('auth');
@@ -872,89 +870,8 @@ class Pages extends Controller
*/
protected function contentForWithdrawalManagement(): array
{
- $clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
- $platform = $clean(request()->query('platform'));
- $withdrawalPath = $clean(request()->query('withdrawal_path'));
- $storeEntity = $clean(request()->query('store_entity'), 191);
- $keyword = $clean(request()->query('keyword'), 191);
- $balanceStatus = $clean(request()->query('balance_status'));
- if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
- $balanceStatus = '';
- }
-
- $platforms = StoreReceivingAccount::query()
- ->whereNotNull('store_platform')
- ->distinct()
- ->orderBy('store_platform')
- ->pluck('store_platform')
- ->filter()
- ->values()
- ->all();
- $withdrawalPaths = StoreReceivingAccount::query()
- ->whereNotNull('receiving_method')
- ->distinct()
- ->orderBy('receiving_method')
- ->pluck('receiving_method')
- ->filter()
- ->values()
- ->all();
- $storeEntities = StoreReceivingAccount::query()
- ->whereNotNull('store_entity_name')
- ->where('store_entity_name', '<>', '')
- ->distinct()
- ->orderBy('store_entity_name')
- ->pluck('store_entity_name')
- ->filter()
- ->values()
- ->all();
-
- $query = StoreReceivingAccount::query();
-
- if ($platform !== '') {
- $query->where('store_platform', $platform);
- }
-
- if ($withdrawalPath !== '') {
- $query->where('receiving_method', $withdrawalPath);
- }
-
- if ($storeEntity !== '') {
- $query->where('store_entity_name', $storeEntity);
- }
-
- if ($keyword !== '') {
- $query->where('store_name', 'like', '%' . addcslashes($keyword, '%_\\') . '%');
- }
-
- $today = now()->startOfDay();
- $balanceDate = $today->copy()->subDay();
- $withdrawalRows = $query->orderByDesc('id')->get();
- $withdrawalBalances = $this->jushuitanShopBalances->matchesForStores(
- $withdrawalRows,
- $balanceDate->toDateString(),
- );
- $balanceCounts = collect($withdrawalBalances)->countBy(
- static fn (array $match): string => $match['status'],
- );
-
- if ($balanceStatus !== '') {
- $withdrawalRows = $withdrawalRows->filter(
- static fn (StoreReceivingAccount $row): bool => ($withdrawalBalances[(int) $row->id]['status'] ?? 'not_ready') === $balanceStatus,
- );
- }
-
- $statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3];
- $withdrawalRows = $withdrawalRows->sort(
- static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($withdrawalBalances, $statusOrder): int {
- $leftStatus = $withdrawalBalances[(int) $left->id]['status'] ?? 'not_ready';
- $rightStatus = $withdrawalBalances[(int) $right->id]['status'] ?? 'not_ready';
- $statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99);
-
- return $statusComparison !== 0
- ? $statusComparison
- : ((int) $right->id <=> (int) $left->id);
- },
- )->values();
+ $report = $this->withdrawalManagementReport->results(request());
+ $withdrawalRows = $report['rows'];
$page = max((int) request()->query('page', 1), 1);
$perPage = 50;
@@ -965,33 +882,18 @@ class Pages extends Controller
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
- $withdrawalReserveStandards = $withdrawalRows->getCollection()->mapWithKeys(
- static fn (StoreReceivingAccount $row): array => [
- (int) $row->id => WithdrawalReserveStandard::amountFor(
- $row->store_platform,
- $row->erp_store_name,
- ),
- ],
- )->all();
-
return [
'view' => 'business.pages.fund-management.withdrawal-management',
'withdrawalRows' => $withdrawalRows,
- 'withdrawalFilters' => compact('platform', 'withdrawalPath', 'storeEntity', 'keyword', 'balanceStatus'),
- 'withdrawalPlatforms' => $platforms,
- 'withdrawalPaths' => $withdrawalPaths,
- 'withdrawalStoreEntities' => $storeEntities,
- 'withdrawalBalances' => $withdrawalBalances,
- 'withdrawalReserveStandards' => $withdrawalReserveStandards,
- 'withdrawalBalanceSummary' => [
- 'source' => $this->jushuitanShopBalances->countForDate($balanceDate->toDateString()),
- 'matched' => (int) $balanceCounts->get('matched', 0),
- 'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
- 'unmatched' => (int) $balanceCounts->get('unmatched', 0),
- 'not_ready' => (int) $balanceCounts->get('not_ready', 0),
- ],
- 'withdrawalBalanceColumnLabel' => $today->format('n月j日') . '店铺余额',
- 'withdrawalBalanceDate' => $balanceDate->toDateString(),
+ 'withdrawalFilters' => $report['filters'],
+ 'withdrawalPlatforms' => $report['platforms'],
+ 'withdrawalPaths' => $report['paths'],
+ 'withdrawalStoreEntities' => $report['storeEntities'],
+ 'withdrawalBalances' => $report['balances'],
+ 'withdrawalReserveStandards' => $report['reserveStandards'],
+ 'withdrawalBalanceSummary' => $report['summary'],
+ 'withdrawalBalanceColumnLabel' => $report['balanceColumnLabel'],
+ 'withdrawalBalanceDate' => $report['balanceDate'],
];
}
diff --git a/app/Http/Controllers/Business/WithdrawalManagement.php b/app/Http/Controllers/Business/WithdrawalManagement.php
new file mode 100644
index 00000000..12cd0082
--- /dev/null
+++ b/app/Http/Controllers/Business/WithdrawalManagement.php
@@ -0,0 +1,106 @@
+middleware('auth');
+ $this->middleware('company.identify');
+ $this->middleware('business.page:fund-management,withdrawal-management');
+ }
+
+ public function export(Request $request): StreamedResponse
+ {
+ return $this->spreadsheet->export($this->report->results($request));
+ }
+
+ public function template(): StreamedResponse
+ {
+ return $this->spreadsheet->template();
+ }
+
+ public function importPreview(Request $request): RedirectResponse
+ {
+ $file = $request->validate([
+ 'import' => ['required', 'file', 'mimes:xlsx,xls', 'max:10240'],
+ ])['import'];
+ $preview = $this->spreadsheet->preview($file, (int) company_id());
+ $token = Str::random(48);
+ Cache::put($this->cacheKey($token), [
+ 'company_id' => (int) company_id(),
+ 'user_id' => (int) user_id(),
+ 'plan' => $preview['plan'],
+ ], now()->addMinutes(30));
+ unset($preview['plan']);
+
+ return $this->redirect($request)->with(
+ 'withdrawalImportPreview',
+ $preview + ['token' => $token],
+ );
+ }
+
+ public function importCommit(Request $request): RedirectResponse
+ {
+ $token = $request->validate([
+ 'preview_token' => ['required', 'string', 'size:48'],
+ ])['preview_token'];
+ $payload = Cache::pull($this->cacheKey($token));
+
+ if (! is_array($payload)
+ || (int) ($payload['company_id'] ?? 0) !== (int) company_id()
+ || (int) ($payload['user_id'] ?? 0) !== (int) user_id()
+ || ! is_array($payload['plan'] ?? null)) {
+ return $this->redirect($request)->withErrors([
+ 'import' => '导入预览已失效或不属于当前账号,请重新上传并预览。',
+ ]);
+ }
+
+ $updated = $this->spreadsheet->commit($payload['plan'], (int) company_id());
+
+ return $this->redirect($request)->with('success', "提现配置导入完成,共更新 {$updated} 家店铺。");
+ }
+
+ private function cacheKey(string $token): string
+ {
+ return self::PREVIEW_CACHE_PREFIX . hash('sha256', $token);
+ }
+
+ private function redirect(Request $request): RedirectResponse
+ {
+ $filters = $this->report->filters($request);
+ $parameters = [
+ 'section' => 'fund-management',
+ 'page' => 'withdrawal-management',
+ ];
+ $queryNames = [
+ 'platform' => 'platform',
+ 'withdrawalPath' => 'withdrawal_path',
+ 'storeEntity' => 'store_entity',
+ 'keyword' => 'keyword',
+ 'balanceStatus' => 'balance_status',
+ ];
+
+ foreach ($queryNames as $filterName => $queryName) {
+ if ($filters[$filterName] !== '') {
+ $parameters[$queryName] = $filters[$filterName];
+ }
+ }
+
+ return redirect()->route('business.pages.show', $parameters);
+ }
+}
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 507fdfdb..a18138f4 100644
--- a/resources/views/business/pages/fund-management/withdrawal-management.blade.php
+++ b/resources/views/business/pages/fund-management/withdrawal-management.blade.php
@@ -1,4 +1,13 @@
-
+@php
+ $withdrawalImportPreview = session('withdrawalImportPreview');
+ $withdrawalOpenImport = is_array($withdrawalImportPreview) || $errors->hasAny(['import', 'preview_token']);
+ $withdrawalQuery = array_filter(
+ request()->only(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status']),
+ static fn ($value): bool => filled($value),
+ );
+@endphp
+
+
account_balance_wallet
@@ -56,6 +65,14 @@
+
+
+ download
+ 导出
+
@if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status']))
重置
@endif
@@ -138,4 +155,138 @@
@endif
+
+
+
+
+
+ @if (is_array($withdrawalImportPreview))
+
+
文件数据{{ number_format($withdrawalImportPreview['total']) }}
+
拟更新{{ number_format($withdrawalImportPreview['changed']) }}
+
无变化{{ number_format($withdrawalImportPreview['unchanged']) }}
+
+
+ @if ($withdrawalImportPreview['preview'] !== [])
+
+
+ | 店铺平台 | ERP店铺名称 | 平台店铺ID | 变更字段 |
+
+ @foreach ($withdrawalImportPreview['preview'] as $previewRow)
+
+ | {{ $previewRow['platform'] }} |
+ {{ $previewRow['erp_store_name'] }} |
+ {{ $previewRow['store_id'] ?: '-' }} |
+ {{ $previewRow['changes'] }} |
+
+ @endforeach
+
+
+
+ @if ($withdrawalImportPreview['changed'] > count($withdrawalImportPreview['preview']))
+
已显示 20 / {{ number_format($withdrawalImportPreview['changed']) }} 条变更
+ @endif
+ @else
+
文件内容与当前配置一致,没有需要更新的字段。
+ @endif
+
+
+ @else
+ @if ($errors->has('import'))
+
{{ $errors->first('import') }}
+ @endif
+
+ @endif
+
+
+
+
+
+
diff --git a/routes/admin.php b/routes/admin.php
index f255aa7d..9433a4a7 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -8,6 +8,7 @@ use App\Http\Controllers\Business\CourierSurchargeQuotes;
use App\Http\Controllers\Business\CourierSurchargeTypes;
use App\Http\Controllers\Business\ProductPrices;
use App\Http\Controllers\Business\SupplierPayableActions;
+use App\Http\Controllers\Business\WithdrawalManagement;
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
@@ -235,6 +236,19 @@ Route::get('workspace/order-data/bill-offset/export', 'Business\Pages@exportBill
->middleware('business.page:order-data,bill-offset')
->name('business.bill-offset.export');
+Route::prefix('workspace/fund-management/withdrawal-management')
+ ->name('business.withdrawal-management.')
+ ->middleware('business.page:fund-management,withdrawal-management')
+ ->group(function () {
+ Route::get('export', [WithdrawalManagement::class, 'export'])->name('export');
+ Route::get('template', [WithdrawalManagement::class, 'template'])->name('template');
+ Route::post('import/preview', [WithdrawalManagement::class, 'importPreview'])
+ ->middleware('import')
+ ->name('import.preview');
+ Route::post('import/commit', [WithdrawalManagement::class, 'importCommit'])
+ ->name('import.commit');
+ });
+
Route::prefix('workspace/courier-payables/courier-surcharge')
->name('business.courier-surcharge.')
->middleware('business.page:courier-payables,courier-surcharge')
diff --git a/tests/Feature/Business/WithdrawalManagementImportExportTest.php b/tests/Feature/Business/WithdrawalManagementImportExportTest.php
new file mode 100644
index 00000000..9c29221a
--- /dev/null
+++ b/tests/Feature/Business/WithdrawalManagementImportExportTest.php
@@ -0,0 +1,318 @@
+ */
+ private array $temporaryFiles = [];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->user->attachPermission(
+ Permission::where('name', 'read-admin-panel')->firstOrFail(),
+ );
+ $this->withoutMiddleware(ThrottleRequests::class);
+ }
+
+ public function test_page_exposes_import_and_filtered_export_actions(): void
+ {
+ StoreReceivingAccount::query()->create([
+ 'company_id' => $this->company->id,
+ 'store_platform' => '天猫',
+ 'erp_store_name' => '操作按钮测试店',
+ 'store_entity_name' => '按钮测试主体',
+ ]);
+
+ $this->loginAs()->get(route('business.pages.show', [
+ 'section' => 'fund-management',
+ 'page' => 'withdrawal-management',
+ 'platform' => '天猫',
+ ]))
+ ->assertOk()
+ ->assertSeeText('导入')
+ ->assertSeeText('导出')
+ ->assertSee('data-withdrawal-import-open', false)
+ ->assertSee(route('business.withdrawal-management.export', ['platform' => '天猫']), false)
+ ->assertSee(route('business.withdrawal-management.template'), false);
+ }
+
+ public function test_export_contains_all_filtered_rows_and_excludes_passwords(): void
+ {
+ StoreReceivingAccount::query()->create([
+ 'company_id' => $this->company->id,
+ 'store_platform' => '天猫',
+ 'erp_store_name' => '导出目标店',
+ 'store_id' => '00001234',
+ 'store_entity_name' => '导出主体',
+ 'login_password' => 'never-export-this-password',
+ 'withdrawal_path' => '支付宝',
+ 'withdrawal_account_name' => '目标账户',
+ 'bank_card_last_four' => '0098',
+ ]);
+ StoreReceivingAccount::query()->create([
+ 'company_id' => $this->company->id,
+ 'store_platform' => '京东',
+ 'erp_store_name' => '不应导出的店铺',
+ 'store_entity_name' => '其他主体',
+ ]);
+
+ $response = $this->loginAs()->get(route('business.withdrawal-management.export', [
+ 'platform' => '天猫',
+ ]));
+ $response->assertOk()->assertDownload();
+ $path = $this->temporaryPath('withdrawal-export-');
+ file_put_contents($path, $response->streamedContent());
+
+ $spreadsheet = IOFactory::load($path);
+ try {
+ $sheet = $spreadsheet->getActiveSheet();
+ $this->assertSame([
+ ...self::HEADERS,
+ now()->format('n月j日') . '店铺余额',
+ '预留标准',
+ '提现金额',
+ '是否达标',
+ ], $sheet->rangeToArray('A1:K1')[0]);
+ $this->assertSame(2, $sheet->getHighestDataRow());
+ $this->assertSame('导出目标店', $sheet->getCell('B2')->getValue());
+ $this->assertSame('00001234', $sheet->getCell('C2')->getValue());
+ $this->assertSame(DataType::TYPE_STRING, $sheet->getCell('C2')->getDataType());
+ $this->assertSame('0098', $sheet->getCell('G2')->getValue());
+ $this->assertSame(DataType::TYPE_STRING, $sheet->getCell('G2')->getDataType());
+ $values = array_merge(...$sheet->toArray(null, false, false, false));
+ $this->assertNotContains('不应导出的店铺', $values);
+ $this->assertNotContains('never-export-this-password', $values);
+ } finally {
+ $spreadsheet->disconnectWorksheets();
+ }
+ }
+
+ public function test_template_has_import_headers_and_instructions(): void
+ {
+ $response = $this->loginAs()->get(route('business.withdrawal-management.template'));
+ $response->assertOk()->assertDownload('withdrawal_management_import_template.xlsx');
+ $path = $this->temporaryPath('withdrawal-template-');
+ file_put_contents($path, $response->streamedContent());
+
+ $spreadsheet = IOFactory::load($path);
+ try {
+ $this->assertSame(self::HEADERS, $spreadsheet->getSheet(0)->rangeToArray('A1:G1')[0]);
+ $this->assertSame('填写说明', $spreadsheet->getSheet(1)->getTitle());
+ $this->assertStringContainsString('[清空]', (string) $spreadsheet->getSheet(1)->getCell('A4')->getValue());
+ } finally {
+ $spreadsheet->disconnectWorksheets();
+ }
+ }
+
+ public function test_valid_import_is_previewed_before_allowed_fields_are_committed(): void
+ {
+ $store = StoreReceivingAccount::query()->create([
+ 'company_id' => $this->company->id,
+ 'store_platform' => '天猫',
+ 'erp_store_name' => '导入更新店',
+ 'store_id' => 'TM-IMPORT-1',
+ 'store_entity_name' => '原主体',
+ 'login_password' => 'password-remains-private',
+ 'withdrawal_path' => '银行卡',
+ 'withdrawal_account_name' => '原账户',
+ 'bank_card_last_four' => '1111',
+ ]);
+ $this->loginAs();
+ $file = $this->workbook([[
+ '天猫', '导入更新店', 'TM-IMPORT-1', '新主体', '支付宝', '新账户', '0022',
+ ]]);
+
+ $previewResponse = $this->post(route('business.withdrawal-management.import.preview', [
+ 'platform' => '天猫',
+ ]), ['import' => $file]);
+ $previewResponse
+ ->assertRedirect(route('business.pages.show', [
+ 'section' => 'fund-management',
+ 'page' => 'withdrawal-management',
+ 'platform' => '天猫',
+ ]))
+ ->assertSessionHas('withdrawalImportPreview', function (array $preview): bool {
+ return $preview['total'] === 1
+ && $preview['changed'] === 1
+ && $preview['unchanged'] === 0
+ && $preview['preview'][0]['changes'] === '店铺主体、提现路径、提现账户名称、银行卡号后四位';
+ });
+ $this->assertSame('原主体', $store->fresh()->store_entity_name);
+ $preview = $previewResponse->getSession()->get('withdrawalImportPreview');
+
+ $this->post(route('business.withdrawal-management.import.commit', [
+ 'platform' => '天猫',
+ ]), ['preview_token' => $preview['token']])
+ ->assertRedirect()
+ ->assertSessionHas('success', '提现配置导入完成,共更新 1 家店铺。');
+
+ $store = $store->fresh();
+ $this->assertSame('新主体', $store->store_entity_name);
+ $this->assertSame('支付宝', $store->withdrawal_path);
+ $this->assertSame('新账户', $store->withdrawal_account_name);
+ $this->assertSame('0022', $store->bank_card_last_four);
+ $this->assertSame('password-remains-private', $store->login_password);
+ }
+
+ public function test_invalid_bank_card_rejects_the_entire_preview_without_writes(): void
+ {
+ $store = StoreReceivingAccount::query()->create([
+ 'company_id' => $this->company->id,
+ 'store_platform' => '京东',
+ 'erp_store_name' => '非法卡号测试店',
+ 'store_id' => 'JD-INVALID-1',
+ 'store_entity_name' => '原主体',
+ 'withdrawal_path' => '银行卡',
+ ]);
+ $this->loginAs();
+
+ $this->withExceptionHandling()
+ ->post(route('business.withdrawal-management.import.preview'), ['import' => $this->workbook([[
+ '京东', '非法卡号测试店', 'JD-INVALID-1', '不应写入的主体', '', '', '12ab',
+ ]])])
+ ->assertRedirect()
+ ->assertSessionHasErrors('import');
+
+ $this->assertSame('原主体', $store->fresh()->store_entity_name);
+ }
+
+ public function test_clear_marker_clears_nullable_and_required_configuration_fields(): void
+ {
+ $store = StoreReceivingAccount::query()->create([
+ 'company_id' => $this->company->id,
+ 'store_platform' => '拼多多',
+ 'erp_store_name' => '清空配置测试店',
+ 'store_id' => 'PDD-CLEAR-1',
+ 'store_entity_name' => '待清空主体',
+ 'withdrawal_path' => '银行卡',
+ 'withdrawal_account_name' => '待清空账户',
+ 'bank_card_last_four' => '8899',
+ ]);
+ $this->loginAs();
+ $previewResponse = $this->post(route('business.withdrawal-management.import.preview'), [
+ 'import' => $this->workbook([[
+ '拼多多', '清空配置测试店', 'PDD-CLEAR-1', '[清空]', '[清空]', '[清空]', '[清空]',
+ ]]),
+ ]);
+ $preview = $previewResponse->getSession()->get('withdrawalImportPreview');
+
+ $this->post(route('business.withdrawal-management.import.commit'), [
+ 'preview_token' => $preview['token'],
+ ])->assertRedirect()->assertSessionHasNoErrors();
+
+ $store = $store->fresh();
+ $this->assertSame('', $store->getRawOriginal('store_entity_name'));
+ $this->assertSame('', $store->getRawOriginal('receiving_method'));
+ $this->assertNull($store->withdrawal_account_name);
+ $this->assertNull($store->bank_card_last_four);
+ }
+
+ public function test_broken_workbook_is_reported_as_an_import_error(): void
+ {
+ $this->loginAs();
+
+ $this->withExceptionHandling()
+ ->post(route('business.withdrawal-management.import.preview'), [
+ 'import' => UploadedFile::fake()->createWithContent('损坏文件.xlsx', "PK\x03\x04broken"),
+ ])
+ ->assertRedirect()
+ ->assertSessionHasErrors([
+ 'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
+ ]);
+ }
+
+ public function test_commit_rejects_a_preview_when_store_changed_after_preview(): void
+ {
+ $store = StoreReceivingAccount::query()->create([
+ 'company_id' => $this->company->id,
+ 'store_platform' => '抖音',
+ 'erp_store_name' => '并发修改测试店',
+ 'store_id' => 'DY-STALE-1',
+ 'store_entity_name' => '预览前主体',
+ ]);
+ $this->loginAs();
+ $previewResponse = $this->post(route('business.withdrawal-management.import.preview'), [
+ 'import' => $this->workbook([[
+ '抖音', '并发修改测试店', 'DY-STALE-1', '导入主体', '', '', '',
+ ]]),
+ ]);
+ $preview = $previewResponse->getSession()->get('withdrawalImportPreview');
+
+ DB::table('business_store_receiving_accounts')->where('id', $store->id)->update([
+ 'store_entity_name' => '他人已修改主体',
+ 'updated_at' => now()->addMinute(),
+ ]);
+
+ $this->withExceptionHandling()
+ ->post(route('business.withdrawal-management.import.commit'), [
+ 'preview_token' => $preview['token'],
+ ])
+ ->assertRedirect()
+ ->assertSessionHasErrors('import');
+ $this->assertSame('他人已修改主体', $store->fresh()->store_entity_name);
+ }
+
+ /** @param list> $rows */
+ private function workbook(array $rows): UploadedFile
+ {
+ $path = $this->temporaryPath('withdrawal-import-');
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->setTitle('提现管理');
+ $sheet->fromArray(self::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);
+ $this->temporaryFiles[] = $path;
+
+ return $path;
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->temporaryFiles as $path) {
+ @unlink($path);
+ }
+
+ parent::tearDown();
+ }
+}
--
2.47.2