科目映射表:按业务备注/商品名称区分同一账单项目的不同业务,修正重复口径
This commit is contained in:
parent
ddfd4b052f
commit
9b05d99cd8
150
modules/EcommerceFinance/Services/AccountMappingMatcher.php
Normal file
150
modules/EcommerceFinance/Services/AccountMappingMatcher.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,8 @@ class AccountMappingWorkbookImporter
|
||||
private const ALIASES = [
|
||||
'platform' => ['平台', '平台名称', '渠道'],
|
||||
'item' => ['账单项目', '项目', '账单类型', '交易类型', '收支项目'],
|
||||
'memo' => ['财务备注', '二次备注', '备注'],
|
||||
'memo' => ['业务备注', '财务备注', '二次备注', '业务备注关键词', '账务备注', '备注'],
|
||||
'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'],
|
||||
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
|
||||
'category' => ['大类', '业务性质', '科目大类', '分类'],
|
||||
'basis' => ['方向基准', '基准'],
|
||||
|
||||
@ -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>';
|
||||
|
||||
166
tests/Feature/Business/AccountMappingMatcherTest.php
Normal file
166
tests/Feature/Business/AccountMappingMatcherTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user