按财务自动化逻辑给电商平台账单分类用的一张映射表,落在财务平台里维护。 表本身仍沿用「科目对 + 方向基准」模型:每条规则只锁定走哪两个科目、 以及方向基准,不存最终借贷方向;实际记账时按账单在平台上的真实收支代入, 收支与基准相反才互换借贷。 新增能力: - 导入追加:兼容旧版 9 列表头(借/贷(借)出现两次、无方向基准列), 按 平台|账单项目|财务备注 判重,预览显示「识别 N 行 / 可新增 X / 重复 Y」, 可勾选让文件内容覆盖重复项。 - 单条修改:行内编辑,科目用「一级下拉 + 二级联动」,改一级会清掉不属于它的二级, 改大类会顺带带过方向基准。 - 新增、删除。 - 实时校验:一级科目名→编码(查不到即错误)、二级名在「一级科目子树」里找 (0 命中/同名歧义为警告,一级二级同名时跳过)、方向基准与大类是否一致。 不改动任何既有页面:仅新增一个菜单分组「财务规则」和一个页面, 维护接口挂在 workspace/financial-rules/account-mapping/actions 下独立前缀。
155 lines
4.5 KiB
PHP
155 lines
4.5 KiB
PHP
<?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);
|
||
}
|
||
}
|