211 lines
7.7 KiB
PHP
211 lines
7.7 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Validation\ValidationException;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 科目映射表导入。
|
||
*
|
||
* 兼容两套表头:
|
||
* A 旧版 9 列:平台 | 账单项目 | 财务备注 | 票据 | 大类 | 借/贷(借) | 借/贷(二级) | 借/贷(借) | 借/贷(二级)
|
||
* —— 「借/贷(借)」重复出现两次,靠位置区分:前两列是借方、后两列是贷方
|
||
* B 新版 10 列:平台 | 账单项目 | 财务备注 | 票据口径 | 大类 | 借方科目 | 借方二级 | 贷方科目 | 贷方二级 | 方向基准
|
||
*
|
||
* 没有方向基准列时,按大类推导(费用/保证金/推广/暂挂/营业外支出/提现 → 支出基准;收入/转账 → 收入基准)。
|
||
*/
|
||
class AccountMappingWorkbookImporter
|
||
{
|
||
private const ALIASES = [
|
||
'platform' => ['平台', '平台名称', '渠道'],
|
||
'item' => ['账单项目', '项目', '账单类型', '交易类型', '收支项目'],
|
||
'memo' => ['业务备注', '财务备注', '二次备注', '业务备注关键词', '账务备注', '备注'],
|
||
'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'],
|
||
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
|
||
'category' => ['财务类型', '大类', '业务性质', '科目大类', '分类'],
|
||
'feeMemo' => ['费用备注', '费用说明', '费用备注说明'],
|
||
'basis' => ['方向基准', '基准'],
|
||
'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'],
|
||
'dSub' => ['借方二级'],
|
||
'cAcct' => ['贷方科目'],
|
||
'cSub' => ['贷方二级'],
|
||
];
|
||
|
||
private const INVOICE_MAP = [
|
||
'0' => '不开票',
|
||
'0.00' => '不开票',
|
||
'0%' => '不开票',
|
||
'0.06' => '0.06',
|
||
'6%' => '0.06',
|
||
'06' => '0.06',
|
||
'可开6%专票' => '0.06',
|
||
'开6%专票' => '0.06',
|
||
'不开票' => '不开票',
|
||
'不开票(无票)' => '不开票',
|
||
'不开票(无票)' => '不开票',
|
||
'无票' => '不开票',
|
||
'-' => '-',
|
||
'—' => '-',
|
||
'不涉及' => '-',
|
||
];
|
||
|
||
/**
|
||
* @return array{rows:array<int,array<string,string>>,header_row:int,sheet:string}
|
||
*/
|
||
public function parse(UploadedFile $file): array
|
||
{
|
||
$extension = mb_strtolower((string) $file->getClientOriginalExtension());
|
||
|
||
try {
|
||
$readerType = match ($extension) {
|
||
'xlsx' => 'Xlsx',
|
||
'xls' => 'Xls',
|
||
'csv' => 'Csv',
|
||
default => throw new \RuntimeException('Unsupported workbook format.'),
|
||
};
|
||
|
||
$reader = IOFactory::createReader($readerType);
|
||
if ($readerType === 'Csv') {
|
||
$reader->setInputEncoding('UTF-8');
|
||
}
|
||
$spreadsheet = $reader->load($file->getRealPath());
|
||
} catch (Throwable) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '读不出这个文件,请确认是 xlsx / xls / csv,且没有损坏。',
|
||
]);
|
||
}
|
||
|
||
try {
|
||
$best = null;
|
||
foreach ($spreadsheet->getWorksheetIterator() as $sheet) {
|
||
$grid = $sheet->toArray(null, true, false, false);
|
||
$grid = array_values(array_filter($grid, static fn (array $row): bool => $row !== []));
|
||
if ($grid === []) {
|
||
continue;
|
||
}
|
||
$headerRow = $this->locateHeader($grid);
|
||
$score = $headerRow === null ? -1 : count(array_filter($this->mapHeader($grid[$headerRow])));
|
||
if ($score <= 0) {
|
||
continue;
|
||
}
|
||
if ($best === null || $score > $best['score']) {
|
||
$best = ['score' => $score, 'grid' => $grid, 'header_row' => $headerRow, 'sheet' => $sheet->getTitle()];
|
||
}
|
||
}
|
||
} finally {
|
||
$spreadsheet->disconnectWorksheets();
|
||
}
|
||
|
||
if ($best === null) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '没找到表头。至少要有一列「平台」和一列「账单项目」,以及借方、贷方两组科目列。',
|
||
]);
|
||
}
|
||
|
||
$columns = $this->mapHeader($best['grid'][$best['header_row']]);
|
||
$rows = [];
|
||
foreach (array_slice($best['grid'], $best['header_row'] + 1) as $gridRow) {
|
||
$row = [];
|
||
foreach ($columns as $field => $index) {
|
||
if ($index === null) {
|
||
$row[$field] = '';
|
||
continue;
|
||
}
|
||
$value = $gridRow[$index] ?? '';
|
||
$row[$field] = trim((string) (is_scalar($value) ? $value : ''));
|
||
}
|
||
if ($row['platform'] === '' && $row['item'] === '') {
|
||
continue;
|
||
}
|
||
$row['invoice'] = $this->normalizeInvoice($row['invoice']);
|
||
$row = $this->inferBasis($row);
|
||
$rows[] = $row;
|
||
}
|
||
|
||
return ['rows' => $rows, 'header_row' => $best['header_row'], 'sheet' => $best['sheet']];
|
||
}
|
||
|
||
/** @param array<int,mixed> $row */
|
||
private function locateHeader(array $grid): ?int
|
||
{
|
||
foreach ($grid as $index => $row) {
|
||
if (count(array_filter($this->mapHeader($row))) >= 3) {
|
||
return $index;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<int,mixed> $header
|
||
* @return array<string,int|null>
|
||
*/
|
||
private function mapHeader(array $header): array
|
||
{
|
||
$cells = [];
|
||
foreach ($header as $index => $value) {
|
||
$cells[$index] = $this->normalizeHeader((string) (is_scalar($value) ? $value : ''));
|
||
}
|
||
|
||
$columns = [];
|
||
foreach (self::ALIASES as $field => $aliases) {
|
||
$columns[$field] = null;
|
||
foreach ($aliases as $alias) {
|
||
$needle = $this->normalizeHeader($alias);
|
||
$found = array_search($needle, $cells, true);
|
||
if ($found !== false) {
|
||
$columns[$field] = (int) $found;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 旧版表头「借/贷(借)」出现两次、没有「贷方科目」:借方组命中的那列起,往后取 4 列。
|
||
$dAcct = $columns['dAcct'];
|
||
if ($dAcct !== null && $columns['dSub'] === null && $columns['cAcct'] === null && $columns['cSub'] === null) {
|
||
$columns['dSub'] = $dAcct + 1;
|
||
$columns['cAcct'] = $dAcct + 2;
|
||
$columns['cSub'] = $dAcct + 3;
|
||
} elseif ($dAcct === null && $columns['cAcct'] !== null && $columns['dSub'] === null) {
|
||
$columns['dAcct'] = max($columns['cAcct'] - 2, 0);
|
||
$columns['dSub'] = max($columns['cAcct'] - 1, 0);
|
||
$columns['cSub'] = $columns['cAcct'] + 1;
|
||
}
|
||
|
||
return $columns;
|
||
}
|
||
|
||
private function normalizeHeader(string $value): string
|
||
{
|
||
$value = str_replace(['(', ')', '(', ')', ' '], ['(', ')', '(', ')', ''], $value);
|
||
|
||
return trim($value);
|
||
}
|
||
|
||
private function normalizeInvoice(string $value): string
|
||
{
|
||
$value = trim($value);
|
||
if ($value === '') {
|
||
return '';
|
||
}
|
||
|
||
return self::INVOICE_MAP[$value] ?? $value;
|
||
}
|
||
|
||
/** @param array<string,string> $row @return array<string,string> */
|
||
private function inferBasis(array $row): array
|
||
{
|
||
$basis = trim((string) ($row['basis'] ?? ''));
|
||
if ($basis === '') {
|
||
$basis = AccountMappingStore::CATEGORY_BASIS[trim((string) $row['category'])] ?? '';
|
||
}
|
||
$row['basis'] = $basis;
|
||
|
||
return $row;
|
||
}
|
||
}
|