CaiWuXiTong/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php

211 lines
7.7 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 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;
}
}