151 lines
5.5 KiB
PHP
151 lines
5.5 KiB
PHP
<?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);
|
||
}
|
||
}
|