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

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

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

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

155 lines
4.5 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\Support\Facades\File;
/**
* 账套科目索引(resources/data/coa.json)。
*
* 账套是「小企业会计准则 + 5 位一级科目」,层级用点分:5601.05.04。
* 映射表里写的是「一级科目名 + 二级科目名」两列,二级名往往是第 3、4 层,
* 所以按「一级名 → 一级编码 → 编码前缀」这条路去子树里找,才找得准。
*/
class AccountMappingCoa
{
/** @var array<int,array{code:string,name:string,level:int,category:string,balance:string}>|null */
private ?array $accounts = null;
/** @return array<int,array{code:string,name:string,level:int,category:string,balance:string}> */
public function all(): array
{
if ($this->accounts !== null) {
return $this->accounts;
}
$path = resource_path('data/coa.json');
$payload = File::exists($path) ? json_decode((string) File::get($path), true) : null;
$rows = is_array($payload['accounts'] ?? null) ? $payload['accounts'] : [];
$this->accounts = array_map(static fn (array $row): array => [
'code' => (string) ($row[0] ?? ''),
'name' => (string) ($row[1] ?? ''),
'level' => (int) ($row[2] ?? 1),
'category' => (string) ($row[3] ?? ''),
'balance' => (string) ($row[4] ?? ''),
], $rows);
return $this->accounts;
}
/** @return array<int,array{code:string,name:string}> 一级科目(用于下拉第一级) */
public function level1(): array
{
return array_values(array_map(
static fn (array $a): array => ['code' => $a['code'], 'name' => $a['name']],
array_filter($this->all(), static fn (array $a): bool => $a['level'] === 1 && $a['name'] !== '')
));
}
/** @return array<int,array{code:string,name:string,level:int}> 某编码子树下的非一级科目 */
public function descendants(string $code): array
{
if ($code === '') {
return [];
}
$prefix = $code . '.';
$hits = [];
foreach ($this->all() as $a) {
if ($a['code'] !== $code && str_starts_with($a['code'], $prefix)) {
$hits[] = ['code' => $a['code'], 'name' => $a['name'], 'level' => $a['level']];
}
}
return $hits;
}
/** 一级科目名 → 编码(账套里一级科目名唯一) */
public function level1Code(string $name): ?string
{
$name = trim($name);
if ($name === '') {
return null;
}
foreach ($this->all() as $a) {
if ($a['level'] === 1 && $a['name'] === $name) {
return $a['code'];
}
}
return null;
}
/**
* 在「一级科目」子树下按名称找科目编码。
*
* @return array<int,string> 命中编码;空数组 = 不存在;多于 1 个 = 同名歧义
*/
public function resolve(string $level1Name, string $subName): array
{
$subName = trim($subName);
if ($subName === '') {
return [];
}
$base = $this->level1Code($level1Name);
if ($base === null) {
return [];
}
$hits = [];
foreach ($this->all() as $a) {
if ($a['name'] === $subName && ($a['code'] === $base || str_starts_with($a['code'], $base . '.'))) {
$hits[] = $a['code'];
}
}
sort($hits);
return $hits;
}
/** 全账套里同名的编码(用于解释「为什么找不到」) @return array<int,string> */
public function anywhereByName(string $name): array
{
$name = trim($name);
if ($name === '') {
return [];
}
$hits = [];
foreach ($this->all() as $a) {
if ($a['name'] === $name) {
$hits[] = $a['code'];
}
}
sort($hits);
return $hits;
}
/** 名称或编码 → 编码(映射表里偶尔会直接写编码) */
public function toCode(string $level1Token): ?string
{
$token = trim($level1Token);
if ($token === '') {
return null;
}
if (preg_match('/^\d+(\.\d+)*$/', $token) === 1) {
foreach ($this->all() as $a) {
if ($a['code'] === $token) {
return $token;
}
}
return null;
}
return $this->level1Code($token);
}
}