Compare commits
2 Commits
f04f8907ea
...
2f899ece7a
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f899ece7a | |||
| a50d14fc2a |
134
app/Business/WithdrawalManagementReport.php
Normal file
134
app/Business/WithdrawalManagementReport.php
Normal 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();
|
||||
}
|
||||
}
|
||||
422
app/Business/WithdrawalManagementSpreadsheet.php
Normal file
422
app/Business/WithdrawalManagementSpreadsheet.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
106
app/Http/Controllers/Business/WithdrawalManagement.php
Normal file
106
app/Http/Controllers/Business/WithdrawalManagement.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
318
tests/Feature/Business/WithdrawalManagementImportExportTest.php
Normal file
318
tests/Feature/Business/WithdrawalManagementImportExportTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user