CaiWuXiTong/modules/EcommerceFinance/Services/AccountMappingMatcher.php

151 lines
5.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}