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 + +
@@ -56,6 +65,14 @@
+ + + + 导出 + @if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status'])) 重置 @endif @@ -138,4 +155,138 @@ @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