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

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

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

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

197 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;
/**
* 科目映射表校验。
*
* 三档含义(不是拍脑袋的严重度):
* 错误 = 会让程序生成凭证失败,或让账务数字直接错 → 必须改
* 警告 = 账能做、程序能跑,但口径不对 → 影响扣除、税负、可比性
* 提示 = 只是建议 → 做账时留意
*
* 校验只针对「一笔账单按某条规则生成分录」这一件事,不评价业务口径优劣。
*/
class AccountMappingValidator
{
public function __construct(private readonly AccountMappingCoa $coa)
{
}
/**
* @param array<int,array<string,mixed>> $rules
* @return array{issues:array<int,array<int,array{level:string,code:string,message:string}>>,summary:array<string,int>}
*/
public function validate(array $rules): array
{
$issues = [];
$dupIndex = [];
foreach ($rules as $rule) {
$dupIndex[implode('|', [
trim((string) ($rule['platform'] ?? '')),
trim((string) ($rule['item'] ?? '')),
])][] = (int) $rule['id'];
}
foreach ($rules as $rule) {
$id = (int) $rule['id'];
$list = [];
$list = array_merge($list, $this->accountIssues($rule, 'dAcct', 'dSub', '借方'));
$list = array_merge($list, $this->accountIssues($rule, 'cAcct', 'cSub', '贷方'));
// 方向基准
$basis = trim((string) ($rule['basis'] ?? ''));
$category = trim((string) ($rule['category'] ?? ''));
if ($basis === '') {
$list[] = $this->issue('错误', 'basis_missing', '没有填方向基准,程序无法判断该照抄还是互换。');
} elseif (! in_array($basis, AccountMappingStore::BASISES, true)) {
$list[] = $this->issue('错误', 'basis_invalid', "方向基准「{$basis}」不是有效值,只能是支出基准或收入基准。");
} elseif ($category !== '' && isset(AccountMappingStore::CATEGORY_BASIS[$category])) {
$expected = AccountMappingStore::CATEGORY_BASIS[$category];
if ($expected !== $basis) {
$list[] = $this->issue(
'警告',
'basis_mismatch',
"大类「{$category}」按口径应是「{$expected}」,这里填的是「{$basis}」,账单收支与基准不一致时方向会反。"
);
}
}
if ($category === '') {
$list[] = $this->issue('警告', 'category_missing', '没有填大类,方向基准失去参照,也无法按大类筛选。');
}
// 票据口径
$invoice = trim((string) ($rule['invoice'] ?? ''));
if ($invoice === '') {
$list[] = $this->issue('提示', 'invoice_missing', '票据口径为空,汇算清缴时不好判断调增项。');
} elseif (! in_array($invoice, AccountMappingStore::INVOICES, true)) {
$list[] = $this->issue('提示', 'invoice_unknown', "票据口径「{$invoice}」不在常用取值(0.06 / 不开票 / -)内。");
}
// 借贷两边同一个科目
$d = trim((string) ($rule['dAcct'] ?? ''));
$c = trim((string) ($rule['cAcct'] ?? ''));
$ds = trim((string) ($rule['dSub'] ?? ''));
$cs = trim((string) ($rule['cSub'] ?? ''));
if ($d !== '' && $d === $c && $ds === $cs) {
$list[] = $this->issue('警告', 'same_account', '借方和贷方是同一个科目,这笔业务不会有损益影响。');
}
// 同平台同项目重复
$pairKey = trim((string) ($rule['platform'] ?? '')) . '|' . trim((string) ($rule['item'] ?? ''));
if (count($dupIndex[$pairKey] ?? []) > 1) {
$others = array_values(array_diff($dupIndex[$pairKey], [$id]));
$list[] = $this->issue(
'提示',
'duplicate_pair',
'同一平台下这个账单项目还有第 ' . implode('、', $others) . ' 条规则;若单据上无法区分,程序会取先出现的那条。'
);
}
if ($list !== []) {
$issues[$id] = $list;
}
}
$errRows = 0;
$warnRows = 0;
$errCount = 0;
$warnCount = 0;
$tipCount = 0;
foreach ($issues as $list) {
$levels = array_column($list, 'level');
if (in_array('错误', $levels, true)) {
$errRows++;
} elseif (in_array('警告', $levels, true)) {
$warnRows++;
}
foreach ($levels as $level) {
if ($level === '错误') {
$errCount++;
} elseif ($level === '警告') {
$warnCount++;
} else {
$tipCount++;
}
}
}
$total = count($rules);
return [
'issues' => $issues,
'summary' => [
'total' => $total,
'error_rows' => $errRows,
'warning_rows' => $warnRows,
'ok_rows' => max($total - $errRows - $warnRows, 0),
'error_count' => $errCount,
'warning_count' => $warnCount,
'tip_count' => $tipCount,
],
];
}
/**
* @return array<int,array{level:string,code:string,message:string}>
*/
private function accountIssues(array $rule, string $acctField, string $subField, string $side): array
{
$list = [];
$acct = trim((string) ($rule[$acctField] ?? ''));
$sub = trim((string) ($rule[$subField] ?? ''));
if ($acct === '') {
$list[] = $this->issue('错误', 'account_missing', "{$side}科目为空,生成不了分录。");
return $list;
}
$base = $this->coa->toCode($acct);
if ($base === null) {
$anywhere = $this->coa->anywhereByName($acct);
$hint = $anywhere === [] ? '账套里没有这个名称的科目,需要先建科目或改成账套已有的一级科目。'
: '它只存在于 ' . implode('、', $anywhere) . ',不是一级科目,应放到对应的一级科目下面。';
$list[] = $this->issue('错误', 'account_unknown', "{$side}科目「{$acct}」在账套里找不到:{$hint}");
return $list;
}
if ($sub === '' || $sub === $acct) {
return $list;
}
$hits = $this->coa->resolve($acct, $sub);
if (count($hits) === 1) {
return $list;
}
if ($hits === []) {
$anywhere = $this->coa->anywhereByName($sub);
$hint = $anywhere === []
? '账套里没有这个名称,需要新建,或改用已有明细。'
: '它在 ' . implode('、', $anywhere) . ',不挂在「' . $acct . '」下面。';
$list[] = $this->issue('警告', 'sub_unknown', "{$side}二级科目「{$sub}」找不到:{$hint}");
return $list;
}
$list[] = $this->issue(
'警告',
'sub_ambiguous',
"{$side}二级科目「{$sub}」在「{$acct}」下有 " . count($hits) . ' 个(' . implode('、', $hits) . '),程序无法自动选,建议改写为编码。'
);
return $list;
}
/** @return array{level:string,code:string,message:string} */
private function issue(string $level, string $code, string $message): array
{
return ['level' => $level, 'code' => $code, 'message' => $message];
}
}