CaiWuXiTong/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php
yueyang fdb42634f7 新增「财务规则 · 科目映射表」可维护页面
按财务自动化逻辑给电商平台账单分类用的一张映射表,落在财务平台里维护。

表本身仍沿用「科目对 + 方向基准」模型:每条规则只锁定走哪两个科目、
以及方向基准,不存最终借贷方向;实际记账时按账单在平台上的真实收支代入,
收支与基准相反才互换借贷。

新增能力:
- 导入追加:兼容旧版 9 列表头(借/贷(借)出现两次、无方向基准列),
  按 平台|账单项目|财务备注 判重,预览显示「识别 N 行 / 可新增 X / 重复 Y」,
  可勾选让文件内容覆盖重复项。
- 单条修改:行内编辑,科目用「一级下拉 + 二级联动」,改一级会清掉不属于它的二级,
  改大类会顺带带过方向基准。
- 新增、删除。
- 实时校验:一级科目名→编码(查不到即错误)、二级名在「一级科目子树」里找
  (0 命中/同名歧义为警告,一级二级同名时跳过)、方向基准与大类是否一致。

不改动任何既有页面:仅新增一个菜单分组「财务规则」和一个页面,
维护接口挂在 workspace/financial-rules/account-mapping/actions 下独立前缀。
2026-09-29 10:18:07 +08:00

206 lines
7.4 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' => ['财务备注', '二次备注', '备注'],
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
'category' => ['大类', '业务性质', '科目大类', '分类'],
'basis' => ['方向基准', '基准'],
'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'],
'dSub' => ['借方二级'],
'cAcct' => ['贷方科目'],
'cSub' => ['贷方二级'],
];
private const INVOICE_MAP = [
'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;
}
}