CaiWuXiTong/app/Business/WithdrawalManagementSpreadsheet.php

487 lines
21 KiB
PHP
Raw Permalink 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 Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class WithdrawalManagementSpreadsheet
{
private const CONFIG_HEADERS = [
'店铺平台',
'ERP店铺名称',
'平台店铺ID',
'店铺主体',
'提现路径',
'提现账户名称',
'银行卡号(后四位)',
];
private const CLEAR_VALUE = '[清空]';
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
/**
* @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>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|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, 18]);
$instructions = $spreadsheet->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['提现管理导入说明'],
['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'],
['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'],
['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'],
['4. 提现金额须为非负数,最多两位小数,只用于当前页面显示的余额数据日期(最近 5 天内最新可用日期);上传后先预览再确认。'],
], 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);
$balanceDate = $this->currentBalanceDate();
$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]] ?? ''));
}
$values['提现金额'] = isset($indexes['提现金额'])
? trim((string) ($row[$indexes['提现金额']] ?? ''))
: '';
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, $balanceDate);
} 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' => '银行卡号后四位',
'withdrawal_amount' => '提现金额',
'withdrawal_balance_date' => '提现金额',
];
$changedLabels = array_unique(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;
$currentBalanceDate = null;
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' => '预览后店铺配置发生变化,请重新上传并预览。',
]);
}
if (isset($item['changes']['withdrawal_balance_date'])
&& $item['changes']['withdrawal_balance_date'] !== null) {
$currentBalanceDate ??= $this->currentBalanceDate();
if ($item['changes']['withdrawal_balance_date'] !== $currentBalanceDate) {
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;
$withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null;
$compliance = $report['compliance'][(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);
}
if ($withdrawalAmount === null) {
$sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING);
} else {
$sheet->setCellValue([10, $rowNumber], $withdrawalAmount);
}
$sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) {
'yes' => '是',
'no' => '否',
default => '待核验',
}, DataType::TYPE_STRING);
$rowNumber++;
}
if ($rowNumber > 2) {
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle('H2:J' . ($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}”列,请下载最新模板。",
]);
}
}
if (count(array_keys($headers, '提现金额', true)) > 1) {
throw ValidationException::withMessages(['import' => '导入表头只能包含一个“提现金额”列。']);
}
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, string $balanceDate): 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;
}
}
$amount = $values['提现金额'] ?? '';
if ($amount !== '' && $amount !== '待接入') {
if ($amount === self::CLEAR_VALUE) {
if ($account->withdrawal_amount !== null || $account->withdrawal_balance_date !== null) {
$changes['withdrawal_amount'] = null;
$changes['withdrawal_balance_date'] = null;
}
} else {
if (preg_match('/^\d{1,10}(?:\.\d{1,2})?$/', $amount) !== 1) {
throw new \InvalidArgumentException('的提现金额须为非负数,最多两位小数');
}
$normalized = number_format((float) $amount, 2, '.', '');
if ($account->withdrawal_amount !== $normalized || $account->withdrawal_balance_date !== $balanceDate) {
$changes['withdrawal_amount'] = $normalized;
$changes['withdrawal_balance_date'] = $balanceDate;
}
}
}
return $changes;
}
private function currentBalanceDate(): string
{
$today = now()->startOfDay();
return $this->shopBalances->latestBalanceDate($today)
?? $today->copy()->subDay()->toDateString();
}
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',
]);
}
}