487 lines
21 KiB
PHP
487 lines
21 KiB
PHP
<?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',
|
||
]);
|
||
}
|
||
}
|