CaiWuXiTong/app/Business/WithdrawalManagementSpreadsheet.php
92451 3f80b2f226 提现管理增加导入导出 (#256)
增加店铺主体筛选及提现管理 XLSX 导入导出;导入采用预览确认并限制可写字段。
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-09-23 20:05:22 +08:00

423 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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',
]);
}
}