diff --git a/modules/EcommerceFinance/Services/AccountMappingMatcher.php b/modules/EcommerceFinance/Services/AccountMappingMatcher.php new file mode 100644 index 00000000..1c77930f --- /dev/null +++ b/modules/EcommerceFinance/Services/AccountMappingMatcher.php @@ -0,0 +1,150 @@ + $businessRemarks 账单上的业务备注(二次备注/账务备注/二级备注等) + * @return array{rule:array,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 $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 $rule @param array $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); + } +} diff --git a/modules/EcommerceFinance/Services/AccountMappingStore.php b/modules/EcommerceFinance/Services/AccountMappingStore.php index ec911293..18030d05 100644 --- a/modules/EcommerceFinance/Services/AccountMappingStore.php +++ b/modules/EcommerceFinance/Services/AccountMappingStore.php @@ -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'] ?? '')), ]); } diff --git a/modules/EcommerceFinance/Services/AccountMappingValidator.php b/modules/EcommerceFinance/Services/AccountMappingValidator.php index f6f6da39..4752390c 100644 --- a/modules/EcommerceFinance/Services/AccountMappingValidator.php +++ b/modules/EcommerceFinance/Services/AccountMappingValidator.php @@ -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'] ?? '')); + } } diff --git a/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php b/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php index 05193aa6..9ca17f1f 100644 --- a/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php +++ b/modules/EcommerceFinance/Services/AccountMappingWorkbookImporter.php @@ -22,7 +22,8 @@ class AccountMappingWorkbookImporter private const ALIASES = [ 'platform' => ['平台', '平台名称', '渠道'], 'item' => ['账单项目', '项目', '账单类型', '交易类型', '收支项目'], - 'memo' => ['财务备注', '二次备注', '备注'], + 'memo' => ['业务备注', '财务备注', '二次备注', '业务备注关键词', '账务备注', '备注'], + 'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'], 'invoice' => ['票据口径', '票据', '票据类型', '发票'], 'category' => ['大类', '业务性质', '科目大类', '分类'], 'basis' => ['方向基准', '基准'], diff --git a/resources/views/business/pages/financial-rules/account-mapping.blade.php b/resources/views/business/pages/financial-rules/account-mapping.blade.php index 784b6750..179109d6 100644 --- a/resources/views/business/pages/financial-rules/account-mapping.blade.php +++ b/resources/views/business/pages/financial-rules/account-mapping.blade.php @@ -290,6 +290,8 @@
· 基准为 收入基准 的项目 —— 账单收入时照抄表内方向;账单支出(退款 / 冲减)时借贷互换。
+ · 同一个账单项目在业务上不是同一件事时,用业务备注/商品名称关键词区分:业务备注匹配账单的二次备注、账务备注等(202607订单 这类月份式备注不参与匹配),商品名称按包含匹配;两个都填最优先,其次商品名称,再次业务备注,都不填就是兜底规则。 +
点行展开看两种收支下的最终分录与校验结果;点「编辑」可直接改这一行;有新增账单项目时用「导入」追加。 @@ -368,7 +370,8 @@ 序号 平台 账单项目 - 财务备注 + 业务备注 + 商品名称关键词 票据 大类 借方科目 @@ -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 @@ + '' + (index + 1) + '' + '' + '' - + '' + + '' + + '' + '' + '' + '' + acctEditor('dAcct', 'dSub') + '' @@ -555,6 +559,7 @@ + '' + esc(r.platform) + '' + '▶ ' + esc(r.item) + '' + '' + esc(r.memo) + '' + + '' + esc(r.goods) + '' + '' + esc(r.invoice || '—') + '' + '' + esc(r.category) + '' + '' + acct(r.dAcct, r.dSub) + '' @@ -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 @@ + '新' + '' + '' - + '' + + '' + + '' + '' + '' + '' + acctEditor('dAcct', 'dSub') + '' @@ -867,11 +873,12 @@ function showImportPreview(data, file) { var rows = (data.sample || []).map(function (r) { return '' + esc(r.platform) + '' + esc(r.item) + '' + esc(r.memo) + '' + + '' + esc(r.goods) + '' + '' + esc(r.category) + '' + acct(r.dAcct, r.dSub) + '' + acct(r.cAcct, r.cSub) + '' + '' + esc(r.basis) + ''; }).join(''); var dupList = (data.duplicate_keys || []).length - ? '
重复的键(平台 | 项目 | 备注)前几个:
' + (data.duplicate_keys || []).map(esc).join('
') + '
' + ? '
重复的键(平台 | 项目 | 业务备注 | 商品名称)前几个:
' + (data.duplicate_keys || []).map(esc).join('
') + '
' : ''; var html = '
文件:' + esc(file.name) + '(工作表 ' + esc(data.sheet) + ')
' + '
' @@ -882,7 +889,7 @@ + '' + dupList + '
前 ' + (data.sample || []).length + ' 行预览:
' - + '' + rows + '
平台账单项目备注大类借方贷方基准
'; + + '' + rows + '
平台账单项目业务备注商品名称大类借方贷方基准
'; var foot = '' + ''; diff --git a/tests/Feature/Business/AccountMappingMatcherTest.php b/tests/Feature/Business/AccountMappingMatcherTest.php new file mode 100644 index 00000000..6d9c5501 --- /dev/null +++ b/tests/Feature/Business/AccountMappingMatcherTest.php @@ -0,0 +1,166 @@ +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 $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 $ids @return array */ + 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; + } +}