科目映射表:按业务备注/商品名称区分同一账单项目的不同业务,修正重复口径

This commit is contained in:
yueyang 2026-09-29 17:27:00 +08:00 committed by yueyang
parent ddfd4b052f
commit 9b05d99cd8
6 changed files with 381 additions and 19 deletions

View File

@ -0,0 +1,150 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 账单 → 科目映射规则 的匹配器。
*
* 规则用两个可选的「区分条件」描述业务性质:
* - 业务备注关键词(规则里的 memo):匹配账单的 二次备注 / 账务备注 / 二级备注 等业务备注列;
* 形如 202607订单 / 2311退款 的「月份式备注」不参与匹配,否则规则会随月份膨胀。
* - 商品名称关键词(规则里的 goods):匹配账单的 商品名称 / 商品描述,按包含匹配。
*
* 匹配优先级(命中即取,条件越具体越优先):
* 1. 商品名称关键词 + 业务备注关键词 都命中
* 2. 只有商品名称关键词命中
* 3. 只有业务备注关键词命中
* 4. 两个条件都为空 = 兜底规则
* 同分时取 id 小的那条(先建的优先)。
* 完全没有命中时:回落到同平台同项目的兜底规则;连兜底都没有才取第一条,并在返回值里标 matched=false。
*/
class AccountMappingMatcher
{
/** 月份式备注:202607订单 / 2607退款 / 2026-07结算 / 之前订单 等 */
private const PERIOD_PATTERN = '/^\s*(?:之前|今前|往期)?\s*\d{4,6}\s*(?:年|月|季度)?\s*(?:订单|退款|结算|账单)?\s*$/u';
public function __construct(private readonly AccountMappingStore $store)
{
}
/**
* @param array<int,string> $businessRemarks 账单上的业务备注(二次备注/账务备注/二级备注等)
* @return array{rule:array<string,mixed>,matched:bool,reason:string}|null
*/
public function match(string $platform, string $item, array $businessRemarks = [], string $goodsName = ''): ?array
{
$candidates = [];
foreach ($this->store->all() as $rule) {
if ($this->sameText((string) $rule['platform'], $platform) && $this->sameText((string) $rule['item'], $item)) {
$candidates[] = $rule;
}
}
if ($candidates === []) {
return null;
}
$hits = [];
$fallback = null;
foreach ($candidates as $rule) {
$memo = trim((string) ($rule['memo'] ?? ''));
$goods = trim((string) ($rule['goods'] ?? ''));
if ($memo === '' && $goods === '') {
$fallback ??= $rule;
continue;
}
if ($memo !== '' && ! $this->rematchMemo($memo, $businessRemarks)) {
continue;
}
if ($goods !== '' && ! $this->containsKeyword($goodsName, $goods)) {
continue;
}
$hits[] = ['rule' => $rule, 'score' => ($goods !== '' ? 2 : 0) + ($memo !== '' ? 1 : 0)];
}
if ($hits !== []) {
usort($hits, static fn (array $a, array $b): int => $b['score'] <=> $a['score'] ?: ((int) $a['rule']['id'] <=> (int) $b['rule']['id']));
return [
'rule' => $hits[0]['rule'],
'matched' => true,
'reason' => $this->hitsReason($hits[0]['rule'], $businessRemarks, $goodsName),
];
}
$rule = $fallback ?? $candidates[0];
return [
'rule' => $rule,
'matched' => false,
'reason' => $fallback !== null
? '没有命中具体条件,用同平台同项目的兜底规则'
: '没有命中任何条件,也没有兜底规则,暂按第一条处理(需要在映射表里补条件)',
];
}
/** @param array<int,string> $businessRemarks */
private function rematchMemo(string $keyword, array $businessRemarks): bool
{
foreach ($businessRemarks as $value) {
$value = trim((string) $value);
if ($value === '' || $this->isPeriodLabel($value)) {
continue;
}
if ($this->containsKeyword($value, $keyword)) {
return true;
}
}
return false;
}
/** 形如 202607订单 / 2311退款 的时间标签,不参与业务备注匹配 */
public function isPeriodLabel(string $value): bool
{
return preg_match(self::PERIOD_PATTERN, trim($value)) === 1;
}
private function containsKeyword(string $haystack, string $keyword): bool
{
$needles = preg_split('/[\s,,、|]+/u', $keyword) ?: [];
$haystack = $this->squash($haystack);
foreach ($needles as $needle) {
$needle = $this->squash($needle);
if ($needle !== '' && ! str_contains($haystack, $needle)) {
return false;
}
}
return $haystack !== '';
}
/** 去空格、去大小写差异,便于「推广费-百亿补贴」这类带前后缀的备注命中 */
private function squash(string $value): string
{
return mb_strtolower(preg_replace('/\s+/u', '', $value) ?? $value);
}
private function sameText(string $a, string $b): bool
{
return $this->squash($a) === $this->squash($b);
}
/** @param array<string,mixed> $rule @param array<int,string> $businessRemarks */
private function hitsReason(array $rule, array $businessRemarks, string $goodsName): string
{
$parts = [];
if (trim((string) ($rule['memo'] ?? '')) !== '') {
$parts[] = '业务备注命中「' . trim((string) $rule['memo']) . '」';
}
if (trim((string) ($rule['goods'] ?? '')) !== '') {
$parts[] = '商品名称命中「' . trim((string) $rule['goods']) . '」';
}
return $parts === [] ? '兜底规则' : implode(',', $parts);
}
}

View File

@ -24,12 +24,13 @@ use RuntimeException;
class AccountMappingStore
{
/** 可维护字段(顺序即导出/模板列顺序) */
public const FIELDS = ['platform', 'item', 'memo', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
public const FIELDS = ['platform', 'item', 'memo', 'goods', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
public const LABELS = [
'platform' => '平台',
'item' => '账单项目',
'memo' => '财务备注',
'memo' => '业务备注',
'goods' => '商品名称关键词',
'invoice' => '票据口径',
'category' => '大类',
'dAcct' => '借方科目',
@ -302,13 +303,19 @@ class AccountMappingStore
return $out;
}
/** 判重键:平台 + 账单项目 + 财务备注 */
/**
* 判重键:平台 + 账单项目 + 业务备注关键词 + 商品名称关键词。
*
* 业务备注/商品名称是「区分条件」:同一个账单项目在业务上不同时,靠它们区分;
* 条件完全相同的两条规则才是真重复。
*/
public function keyOf(array $rule): string
{
return implode('|', [
trim((string) ($rule['platform'] ?? '')),
trim((string) ($rule['item'] ?? '')),
trim((string) ($rule['memo'] ?? '')),
trim((string) ($rule['goods'] ?? '')),
]);
}

View File

@ -33,6 +33,14 @@ class AccountMappingValidator
])][] = (int) $rule['id'];
}
// 同一「平台 + 账单项目」下,用区分条件(业务备注 + 商品名称)分组:
// 条件相同的两条 = 真重复(提示);
// 条件不同的多条 = 按条件区分(正常,不报)。
$conditionIndex = [];
foreach ($rules as $rule) {
$conditionIndex[$this->pairKey($rule)][$this->conditionKey($rule)][] = (int) $rule['id'];
}
foreach ($rules as $rule) {
$id = (int) $rule['id'];
$list = [];
@ -83,12 +91,23 @@ class AccountMappingValidator
// 同平台同项目重复
$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) . ' 条规则;若单据上无法区分,程序会取先出现的那条。'
);
$sameCondition = array_values(array_diff($conditionIndex[$pairKey][$this->conditionKey($rule)] ?? [], [$id]));
if ($sameCondition !== []) {
$list[] = $this->issue(
'提示',
'duplicate_pair',
'同一平台、同一账单项目、区分条件也相同的第 ' . implode('、', $sameCondition) . ' 条规则属于重复,建议合并。'
);
}
if (trim((string) ($rule['memo'] ?? '')) === '' && trim((string) ($rule['goods'] ?? '')) === '') {
$list[] = $this->issue(
'提示',
'fallback_rule',
'这条没有区分条件,属于兜底规则:只有在同项目下其它带条件的规则都没命中时才会用它。'
);
}
}
if ($list !== []) {
@ -193,4 +212,16 @@ class AccountMappingValidator
{
return ['level' => $level, 'code' => $code, 'message' => $message];
}
/** 平台 + 账单项目 */
private function pairKey(array $rule): string
{
return trim((string) ($rule['platform'] ?? '')) . '|' . trim((string) ($rule['item'] ?? ''));
}
/** 区分条件:业务备注关键词 + 商品名称关键词 */
private function conditionKey(array $rule): string
{
return trim((string) ($rule['memo'] ?? '')) . '|' . trim((string) ($rule['goods'] ?? ''));
}
}

View File

@ -22,7 +22,8 @@ class AccountMappingWorkbookImporter
private const ALIASES = [
'platform' => ['平台', '平台名称', '渠道'],
'item' => ['账单项目', '项目', '账单类型', '交易类型', '收支项目'],
'memo' => ['财务备注', '二次备注', '备注'],
'memo' => ['业务备注', '财务备注', '二次备注', '业务备注关键词', '账务备注', '备注'],
'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'],
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
'category' => ['大类', '业务性质', '科目大类', '分类'],
'basis' => ['方向基准', '基准'],

View File

@ -290,6 +290,8 @@
<br>
· 基准为 <span class="am-basis am-basis--in">收入基准</span> 的项目 —— 账单<b>收入</b>时<b>照抄</b>表内方向;账单<b>支出</b>(退款 / 冲减)时<b>借贷互换</b>。
<br>
· 同一个账单项目在业务上不是同一件事时,用<b>业务备注</b>/<b>商品名称关键词</b>区分:业务备注匹配账单的二次备注、账务备注等(<b>202607订单</b> 这类月份式备注不参与匹配),商品名称按包含匹配;两个都填最优先,其次商品名称,再次业务备注,都不填就是兜底规则。
<br>
<span style="color:#8a8377;">点行展开看两种收支下的最终分录与校验结果;点「编辑」可直接改这一行;有新增账单项目时用「导入」追加。</span>
</section>
@ -368,7 +370,8 @@
<th class="am-index" style="width:3.5rem;">序号</th>
<th style="width:4.5rem;">平台</th>
<th style="width:17rem;">账单项目</th>
<th style="width:12rem;">财务备注</th>
<th style="width:11rem;">业务备注</th>
<th style="width:11rem;">商品名称关键词</th>
<th style="width:5rem;">票据</th>
<th style="width:5rem;">大类</th>
<th style="width:11rem;">借方科目</th>
@ -523,7 +526,7 @@
if (fOnly.value === 'issue' && !n.error && !n.warning && !n.tip) { return false; }
}
if (keyword) {
var hay = [r.platform, r.item, r.memo, r.dAcct, r.dSub, r.cAcct, r.cSub, r.category, r.basis]
var hay = [r.platform, r.item, r.memo, r.goods, r.dAcct, r.dSub, r.cAcct, r.cSub, r.category, r.basis]
.join(' ').toLowerCase();
if (hay.indexOf(keyword) < 0) { return false; }
}
@ -538,7 +541,8 @@
+ '<td class="am-index">' + (index + 1) + '</td>'
+ '<td class="am-edit-cell"><input data-f="platform" value="' + esc(draft.platform) + '" list="amPlatformList"></td>'
+ '<td class="am-edit-cell"><input data-f="item" value="' + esc(draft.item) + '"></td>'
+ '<td class="am-edit-cell"><input data-f="memo" value="' + esc(draft.memo) + '"></td>'
+ '<td class="am-edit-cell"><input data-f="memo" value="' + esc(draft.memo) + '" placeholder="业务备注关键词"></td>'
+ '<td class="am-edit-cell"><input data-f="goods" value="' + esc(draft.goods) + '" placeholder="商品名称关键词"></td>'
+ '<td class="am-edit-cell"><select data-f="invoice">' + optionsHtml(OPTIONS.invoices || [], draft.invoice) + '</select></td>'
+ '<td class="am-edit-cell"><select data-f="category">' + optionsHtml(OPTIONS.categories || [], draft.category) + '</select></td>'
+ '<td class="am-edit-cell">' + acctEditor('dAcct', 'dSub') + '</td>'
@ -555,6 +559,7 @@
+ '<td>' + esc(r.platform) + '</td>'
+ '<td><span class="am-caret">▶</span> <span title="' + esc(r.item) + '">' + esc(r.item) + '</span></td>'
+ '<td><span title="' + esc(r.memo) + '">' + esc(r.memo) + '</span></td>'
+ '<td><span title="' + esc(r.goods) + '">' + esc(r.goods) + '</span></td>'
+ '<td>' + esc(r.invoice || '—') + '</td>'
+ '<td>' + esc(r.category) + '</td>'
+ '<td>' + acct(r.dAcct, r.dSub) + '</td>'
@ -759,7 +764,7 @@
function collectDraft() {
var values = {};
['platform', 'item', 'memo', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'].forEach(function (f) {
['platform', 'item', 'memo', 'goods', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'].forEach(function (f) {
values[f] = draft[f] == null ? '' : draft[f];
});
return values;
@ -770,7 +775,7 @@
var r = ROWS.filter(function (x) { return x.id === id; })[0];
if (!r) { return; }
draft = {
platform: r.platform, item: r.item, memo: r.memo, invoice: r.invoice,
platform: r.platform, item: r.item, memo: r.memo, goods: r.goods, invoice: r.invoice,
category: r.category, dAcct: r.dAcct, dSub: r.dSub, cAcct: r.cAcct, cSub: r.cSub, basis: r.basis
};
editingId = id;
@ -801,7 +806,7 @@
if (editingId) { toast('请先保存或取消正在编辑的那一行。', 'err'); return; }
loadCoa().then(function () {
draft = {
platform: '', item: '', memo: '', invoice: '-', category: '费用',
platform: '', item: '', memo: '', goods: '', invoice: '-', category: '费用',
dAcct: '', dSub: '', cAcct: '', cSub: '', basis: '支出基准'
};
editingId = 'new';
@ -817,7 +822,8 @@
+ '<td>新</td>'
+ '<td class="am-edit-cell"><input data-f="platform" value="' + esc(draft.platform) + '" placeholder="平台"></td>'
+ '<td class="am-edit-cell"><input data-f="item" value="' + esc(draft.item) + '" placeholder="账单项目上的名称"></td>'
+ '<td class="am-edit-cell"><input data-f="memo" value="' + esc(draft.memo) + '" placeholder="财务备注"></td>'
+ '<td class="am-edit-cell"><input data-f="memo" value="' + esc(draft.memo) + '" placeholder="业务备注关键词"></td>'
+ '<td class="am-edit-cell"><input data-f="goods" value="' + esc(draft.goods) + '" placeholder="商品名称关键词"></td>'
+ '<td class="am-edit-cell"><select data-f="invoice">' + optionsHtml(OPTIONS.invoices || [], draft.invoice) + '</select></td>'
+ '<td class="am-edit-cell"><select data-f="category">' + optionsHtml(OPTIONS.categories || [], draft.category) + '</select></td>'
+ '<td class="am-edit-cell">' + acctEditor('dAcct', 'dSub') + '</td>'
@ -867,11 +873,12 @@
function showImportPreview(data, file) {
var rows = (data.sample || []).map(function (r) {
return '<tr><td>' + esc(r.platform) + '</td><td>' + esc(r.item) + '</td><td>' + esc(r.memo) + '</td>'
+ '<td>' + esc(r.goods) + '</td>'
+ '<td>' + esc(r.category) + '</td><td>' + acct(r.dAcct, r.dSub) + '</td><td>' + acct(r.cAcct, r.cSub) + '</td>'
+ '<td>' + esc(r.basis) + '</td></tr>';
}).join('');
var dupList = (data.duplicate_keys || []).length
? '<div style="margin-top:0.5rem;color:#8a5d10;">重复的键(平台 | 项目 | 备注)前几个:<br>' + (data.duplicate_keys || []).map(esc).join('<br>') + '</div>'
? '<div style="margin-top:0.5rem;color:#8a5d10;">重复的键(平台 | 项目 | 业务备注 | 商品名称)前几个:<br>' + (data.duplicate_keys || []).map(esc).join('<br>') + '</div>'
: '';
var html = '<div>文件:<b>' + esc(file.name) + '</b>(工作表 ' + esc(data.sheet) + ')</div>'
+ '<div class="am-kv">'
@ -882,7 +889,7 @@
+ '<label class="am-check"><input type="checkbox" id="amOverwrite"> 重复的那些用文件里的内容覆盖现有规则(不勾选则跳过)</label>'
+ dupList
+ '<div style="margin-top:0.75rem;">前 ' + (data.sample || []).length + ' 行预览:</div>'
+ '<table class="am-preview-table"><thead><tr><th>平台</th><th>账单项目</th><th>备注</th><th>大类</th><th>借方</th><th>贷方</th><th>基准</th></tr></thead><tbody>' + rows + '</tbody></table>';
+ '<table class="am-preview-table"><thead><tr><th>平台</th><th>账单项目</th><th>业务备注</th><th>商品名称</th><th>大类</th><th>借方</th><th>贷方</th><th>基准</th></tr></thead><tbody>' + rows + '</tbody></table>';
var foot = '<button type="button" class="am-btn" id="amImportCancel">取消</button>'
+ '<button type="button" class="am-btn am-btn--primary" id="amImportConfirm">确认导入</button>';

View File

@ -0,0 +1,166 @@
<?php
namespace Tests\Feature\Business;
use Illuminate\Support\Facades\File;
use Modules\EcommerceFinance\Services\AccountMappingMatcher;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Tests\Feature\FeatureTestCase;
/**
* 科目映射表的「区分条件」匹配:业务备注 / 商品名称关键词。
*
* 数据同样落在 storage_path('app/account-mapping'),所以每个用例都把 storage 指到临时目录。
*/
class AccountMappingMatcherTest extends FeatureTestCase
{
private string $storageRoot = '';
private AccountMappingStore $store;
private AccountMappingMatcher $matcher;
protected function setUp(): void
{
parent::setUp();
$this->storageRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'account-mapping-matcher-' . uniqid();
File::makeDirectory($this->storageRoot . '/app', 0777, true, true);
$this->app->useStoragePath($this->storageRoot);
$this->store = app(AccountMappingStore::class);
$this->matcher = app(AccountMappingMatcher::class);
}
protected function tearDown(): void
{
if ($this->storageRoot !== '' && File::isDirectory($this->storageRoot)) {
File::deleteDirectory($this->storageRoot);
}
parent::tearDown();
}
/** @param array<string,string> $values */
private function rule(array $values): array
{
return $this->store->create($values + [
'platform' => '测试平台',
'item' => '测试项目',
'category' => '费用',
'dAcct' => '销售费用',
'dSub' => '运费',
'cAcct' => '其他货币资金',
'cSub' => '',
'basis' => '支出基准',
]);
}
/** 同项目两条规则:带业务备注的优先于不带条件的兜底规则 */
public function test_specific_rule_wins_over_fallback(): void
{
$fallback = $this->rule(['item' => '在线支付']);
$erp = $this->rule(['item' => '在线支付', 'memo' => 'ERP软件费', 'dAcct' => '管理费用', 'dSub' => '软件服务费']);
$hit = $this->matcher->match('测试平台', '在线支付', ['ERP软件费'], '聚水潭ERP-专业版');
$this->assertNotNull($hit);
$this->assertTrue($hit['matched']);
$this->assertSame($erp['id'], $hit['rule']['id']);
$miss = $this->matcher->match('测试平台', '在线支付', ['推广充值'], 'P4P充值包');
$this->assertNotNull($miss);
$this->assertFalse($miss['matched']);
$this->assertSame($fallback['id'], $miss['rule']['id']);
$this->assertStringContainsString('兜底', $miss['reason']);
}
/** 商品名称关键词比业务备注更具体:两个都命中时取商品名称那条 */
public function test_goods_keyword_is_more_specific_than_remark(): void
{
$byMemo = $this->rule(['item' => '在线支付', 'memo' => '推广充值']);
$byGoods = $this->rule(['item' => '在线支付', 'memo' => '推广充值', 'goods' => 'P4P充值包']);
$hit = $this->matcher->match('测试平台', '在线支付', ['推广充值'], 'P4P充值包-2026年7月');
$this->assertNotNull($hit);
$this->assertSame($byGoods['id'], $hit['rule']['id']);
$other = $this->matcher->match('测试平台', '在线支付', ['推广充值'], '万相台充值');
$this->assertNotNull($other);
$this->assertSame($byMemo['id'], $other['rule']['id']);
}
/** 月份式备注(2607退款 / 202607订单)不参与匹配,否则规则会随月份膨胀 */
public function test_period_labels_do_not_match_business_remark(): void
{
$rule = $this->rule(['item' => '退款', 'memo' => '退款']);
$this->assertTrue($this->matcher->isPeriodLabel('2607退款'));
$this->assertTrue($this->matcher->isPeriodLabel('202607订单'));
$period = $this->matcher->match('测试平台', '退款', ['2607退款']);
$this->assertNotNull($period);
$this->assertFalse($period['matched']);
$business = $this->matcher->match('测试平台', '退款', ['退款']);
$this->assertNotNull($business);
$this->assertTrue($business['matched']);
$this->assertSame($rule['id'], $business['rule']['id']);
}
/** 商品名称关键词支持空格分隔的多个词,全部命中才算 */
public function test_goods_keyword_supports_multiple_terms(): void
{
$rule = $this->rule(['item' => '在线支付', 'goods' => '聚水潭 ERP']);
$hit = $this->matcher->match('测试平台', '在线支付', [], '聚水潭ERP-专业版-订单付款');
$this->assertNotNull($hit);
$this->assertTrue($hit['matched']);
$this->assertSame($rule['id'], $hit['rule']['id']);
$miss = $this->matcher->match('测试平台', '在线支付', [], '聚水潭-专业版');
$this->assertNotNull($miss);
$this->assertFalse($miss['matched']);
}
/** 校验:同项目、条件不同 = 正常;条件相同 = 才算重复 */
public function test_validator_flags_only_same_condition_pairs(): void
{
$first = $this->rule(['item' => '交易分账_货款', 'memo' => '订单收入']);
$second = $this->rule(['item' => '交易分账_货款', 'memo' => '退款']);
$this->assertNotContains('duplicate_pair', $this->issueCodes([$first['id'], $second['id']]), '条件不同的两条不该报重复');
$third = $this->rule(['item' => '交易分账_货款', 'memo' => '退款']);
$this->assertContains('duplicate_pair', $this->issueCodes([$second['id'], $third['id']]), '条件相同的两条要报重复');
}
/** 校验:同项目下有兜底规则时给出提示,提醒它只做兜底 */
public function test_validator_hints_fallback_rule(): void
{
$fallback = $this->rule(['item' => '在线支付']);
$specific = $this->rule(['item' => '在线支付', 'memo' => 'ERP软件费']);
$codes = $this->issueCodes([$fallback['id']]);
$this->assertContains('fallback_rule', $codes);
$this->assertNotContains('duplicate_pair', $codes);
$this->assertNotContains('duplicate_pair', $this->issueCodes([$specific['id']]));
}
/** @param array<int,int> $ids @return array<int,string> */
private function issueCodes(array $ids): array
{
$result = app(AccountMappingValidator::class)->validate($this->store->all());
$codes = [];
foreach ($ids as $id) {
foreach ($result['issues'][$id] ?? [] as $issue) {
$codes[] = $issue['code'];
}
}
return $codes;
}
}