197 lines
7.7 KiB
PHP
197 lines
7.7 KiB
PHP
<?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];
|
||
}
|
||
}
|