Compare commits

...

2 Commits

Author SHA1 Message Date
2f899ece7a feat: add withdrawal import and export 2026-09-23 20:01:16 +08:00
a50d14fc2a feat: add withdrawal store entity filter 2026-09-23 20:01:16 +08:00
8 changed files with 1185 additions and 98 deletions

View File

@ -0,0 +1,134 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
class WithdrawalManagementReport
{
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
/**
* @return array{
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string},
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,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<string> */
private function options(string $column): array
{
return StoreReceivingAccount::query()
->whereNotNull($column)
->where($column, '<>', '')
->distinct()
->orderBy($column)
->pluck($column)
->filter()
->values()
->all();
}
}

View File

@ -0,0 +1,422 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class WithdrawalManagementSpreadsheet
{
private const CONFIG_HEADERS = [
'店铺平台',
'ERP店铺名称',
'平台店铺ID',
'店铺主体',
'提现路径',
'提现账户名称',
'银行卡号(后四位)',
];
private const CLEAR_VALUE = '[清空]';
/**
* @param array{
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,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<array{platform:string,erp_store_name:string,store_id:string,changes:string}>,
* plan:list<array{id:int,updated_at:string,changes:array<string,string|null>}>
* }
*/
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<array{id:int,updated_at:string,changes:array<string,string|null>}> $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<string>} */
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<string,list<StoreReceivingAccount>>,1:array<string,list<StoreReceivingAccount>>} */
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<string,string> $values @return array<string,string|null> */
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',
]);
}
}

View File

@ -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,75 +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'));
$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();
$query = StoreReceivingAccount::query();
if ($platform !== '') {
$query->where('store_platform', $platform);
}
if ($withdrawalPath !== '') {
$query->where('receiving_method', $withdrawalPath);
}
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;
@ -951,32 +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', 'keyword', 'balanceStatus'),
'withdrawalPlatforms' => $platforms,
'withdrawalPaths' => $withdrawalPaths,
'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'],
];
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Business;
use App\Business\WithdrawalManagementReport;
use App\Business\WithdrawalManagementSpreadsheet;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class WithdrawalManagement extends Controller
{
private const PREVIEW_CACHE_PREFIX = 'withdrawal-management-import:';
public function __construct(
private readonly WithdrawalManagementReport $report,
private readonly WithdrawalManagementSpreadsheet $spreadsheet,
) {
$this->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);
}
}

View File

@ -1,4 +1,13 @@
<section class="biz-card mt-6" aria-label="提现配置列表">
@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
<section class="biz-card mt-6 withdrawal-management" aria-label="提现配置列表">
<div class="biz-card-head">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">account_balance_wallet</span>
@ -32,6 +41,15 @@
@endforeach
</select>
</div>
<div class="biz-filter-field">
<label for="withdrawal-store-entity">店铺主体</label>
<select id="withdrawal-store-entity" class="biz-filter-select" name="store_entity">
<option value="">全部</option>
@foreach ($withdrawalStoreEntities as $option)
<option value="{{ $option }}" @selected($withdrawalFilters['storeEntity'] === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="biz-filter-field">
<label for="withdrawal-keyword">ERP店铺名称</label>
<input id="withdrawal-keyword" class="biz-filter-input" type="search" name="keyword" value="{{ $withdrawalFilters['keyword'] }}" placeholder="输入店铺名称">
@ -47,7 +65,15 @@
</select>
</div>
<button type="submit" class="biz-btn biz-btn-primary">筛选</button>
@if (request()->hasAny(['platform', 'withdrawal_path', 'keyword', 'balance_status']))
<button type="button" class="biz-btn biz-btn-ghost" data-withdrawal-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span>导入</span>
</button>
<a class="biz-btn biz-btn-ghost" href="{{ route('business.withdrawal-management.export', $withdrawalQuery) }}">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>导出</span>
</a>
@if (request()->hasAny(['platform', 'withdrawal_path', 'store_entity', 'keyword', 'balance_status']))
<a class="biz-btn biz-btn-ghost" href="{{ request()->url() }}">重置</a>
@endif
</form>
@ -129,4 +155,138 @@
@endif
</span>
</div>
<div
class="biz-modal withdrawal-import-modal {{ $withdrawalOpenImport ? 'is-open' : '' }}"
role="dialog"
aria-modal="true"
aria-labelledby="withdrawal-import-title"
aria-hidden="{{ $withdrawalOpenImport ? 'false' : 'true' }}"
data-withdrawal-import-modal
data-auto-open="{{ $withdrawalOpenImport ? 'true' : 'false' }}"
>
<div class="biz-modal-card">
<div class="withdrawal-import__head">
<div class="biz-modal-title" id="withdrawal-import-title">导入提现配置</div>
<button type="button" class="withdrawal-import__close" title="关闭" aria-label="关闭" data-withdrawal-import-close>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
</div>
@if (is_array($withdrawalImportPreview))
<div class="withdrawal-import__stats" aria-label="导入预览汇总">
<div><span>文件数据</span><strong>{{ number_format($withdrawalImportPreview['total']) }}</strong></div>
<div><span>拟更新</span><strong>{{ number_format($withdrawalImportPreview['changed']) }}</strong></div>
<div><span>无变化</span><strong>{{ number_format($withdrawalImportPreview['unchanged']) }}</strong></div>
</div>
@if ($withdrawalImportPreview['preview'] !== [])
<div class="withdrawal-import__preview">
<table class="biz-table">
<thead><tr><th>店铺平台</th><th>ERP店铺名称</th><th>平台店铺ID</th><th>变更字段</th></tr></thead>
<tbody>
@foreach ($withdrawalImportPreview['preview'] as $previewRow)
<tr>
<td>{{ $previewRow['platform'] }}</td>
<td>{{ $previewRow['erp_store_name'] }}</td>
<td>{{ $previewRow['store_id'] ?: '-' }}</td>
<td>{{ $previewRow['changes'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if ($withdrawalImportPreview['changed'] > count($withdrawalImportPreview['preview']))
<p class="withdrawal-import__status">已显示 20 / {{ number_format($withdrawalImportPreview['changed']) }} 条变更</p>
@endif
@else
<p class="withdrawal-import__empty">文件内容与当前配置一致,没有需要更新的字段。</p>
@endif
<form method="POST" action="{{ route('business.withdrawal-management.import.commit', $withdrawalQuery) }}">
@csrf
<input type="hidden" name="preview_token" value="{{ $withdrawalImportPreview['token'] }}">
<div class="biz-modal-foot">
<button type="button" class="biz-btn biz-btn-ghost" data-withdrawal-import-close>取消</button>
<button type="submit" class="biz-btn biz-btn-primary" @disabled($withdrawalImportPreview['changed'] === 0)>
<span class="material-icons-outlined" aria-hidden="true">save</span>
<span>确认更新 {{ number_format($withdrawalImportPreview['changed']) }} 家店铺</span>
</button>
</div>
</form>
@else
@if ($errors->has('import'))
<div class="withdrawal-import__error" role="alert">{{ $errors->first('import') }}</div>
@endif
<form method="POST" enctype="multipart/form-data" action="{{ route('business.withdrawal-management.import.preview', $withdrawalQuery) }}">
@csrf
<label class="biz-field-label" for="withdrawal-import-file">Excel 文件</label>
<input id="withdrawal-import-file" class="biz-field-input" type="file" name="import" accept=".xlsx,.xls" required>
<div class="withdrawal-import__template">
<a class="biz-btn biz-btn-ghost" href="{{ route('business.withdrawal-management.template') }}">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span>下载导入模板</span>
</a>
</div>
<div class="biz-modal-foot">
<button type="button" class="biz-btn biz-btn-ghost" data-withdrawal-import-close>取消</button>
<button type="submit" class="biz-btn biz-btn-primary">
<span class="material-icons-outlined" aria-hidden="true">preview</span>
<span>上传并预览</span>
</button>
</div>
</form>
@endif
</div>
</div>
</section>
<style>
.withdrawal-management .biz-btn .material-icons-outlined { font-size: 1.1rem; }
.withdrawal-management .biz-btn-primary { border-color: #856632; background: #856632; color: #fff; }
.withdrawal-management .biz-btn-primary:hover { background: #6f5429; }
.withdrawal-import-modal .biz-modal-card { max-width: 820px; border: 1px solid #d8d2c7; border-radius: 8px; }
.withdrawal-import__head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:1rem; }
.withdrawal-import__head .biz-modal-title { margin:0; }
.withdrawal-import__close { display:inline-flex; width:2rem; height:2rem; flex:0 0 2rem; align-items:center; justify-content:center; border:1px solid #d8d2c7; border-radius:6px; color:#34312d; }
.withdrawal-import__close:hover { background:#f3efe6; }
.withdrawal-import__stats { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin:1rem 0; border:1px solid #d8d2c7; }
.withdrawal-import__stats > div { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.75rem 1rem; }
.withdrawal-import__stats > div + div { border-left:1px solid #d8d2c7; }
.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__status { margin:.75rem 0 0; color:#6b665e; font-size:.8125rem; }
.withdrawal-import__template { margin-top:1rem; }
.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) {
.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-modal .biz-modal-foot { align-items:stretch; flex-direction:column-reverse; }
.withdrawal-import-modal .biz-btn { justify-content:center; }
}
</style>
<script>
(() => {
const modal = document.querySelector('[data-withdrawal-import-modal]');
if (!modal) return;
const open = () => {
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
modal.querySelector('input[type="file"], button:not([disabled])')?.focus();
};
const close = () => {
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
};
document.querySelectorAll('[data-withdrawal-import-open]').forEach((button) => button.addEventListener('click', open));
modal.querySelectorAll('[data-withdrawal-import-close]').forEach((button) => button.addEventListener('click', close));
modal.addEventListener('click', (event) => { if (event.target === modal) close(); });
document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && modal.classList.contains('is-open')) close(); });
if (modal.dataset.autoOpen === 'true') open();
})();
</script>

View File

@ -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')

View File

@ -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
'<th>是否达标</th>',
], false)
->assertSee('aria-label="提现管理筛选"', false)
->assertSee('name="store_entity"', false)
->assertSee('value="提现测试主体"', false)
->assertSee('title="聚水潭店铺:天猫-提现测试店铺;余额日期:2026-09-12"', false)
->assertSeeInOrder(['提现测试ERP店', '未接入测试ERP店'])
->assertDontSee('<th>店铺名称</th>', 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

View File

@ -0,0 +1,318 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class WithdrawalManagementImportExportTest extends FeatureTestCase
{
private const HEADERS = [
'店铺平台',
'ERP店铺名称',
'平台店铺ID',
'店铺主体',
'提现路径',
'提现账户名称',
'银行卡号(后四位)',
];
/** @var list<string> */
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<list<mixed>> $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();
}
}