CaiWuXiTong/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php
yueyang 951fde9c46 账单页签:总账备注下拉候选合并科目映射表的财务备注
- 之前候选只来自总账备注映射表,科目映射表里新加的备注在下拉里选不到
- 现在两个来源取并集,改完映射表下一次请求即可筛选
2026-10-10 11:20:18 +08:00

668 lines
23 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;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* 「总账备注」取值:按(平台 + 账单项目)匹配 ec_account_ledger_remarks。
*
* 店铺收款账户账单各页签的账单项目名称不一致,平台口径以店铺名前缀为准
* (如「抖音-xxx」「天猫-xxx」),页签默认平台只作为兜底,避免淘系页签里
* 天猫/淘宝/阿里巴巴三类项目混用同一份映射。
*/
class AccountLedgerRemarkMap
{
public const CACHE_KEY = 'ec_account_ledger_remarks_map_v1';
/** 「平台 + 账单项目」→ 科目映射表规则(请求内缓存,避免逐行扫描全部规则)。 */
private ?array $accountMappingIndex = null;
/** 页签默认平台(店铺名没有平台前缀时使用)。 */
private const TAB_PLATFORM = [
'taobao-alipay' => '淘宝',
'taobao-wechat' => '淘宝',
'pinduoduo' => '拼多多',
'jd-transactions' => '京东',
'jingxi' => '京喜',
'jd-wallet' => '京东',
'douyin' => '抖音',
'dewu' => '得物',
'vipshop' => '唯品会MP',
'vipshop-jx' => '唯品会jx',
];
/**
* 页签 → 该页签可能出现的平台。
*
* 大部分页签只有一个平台;淘系支付宝与淘系微信账户同一页签内混有
* 天猫/淘宝/阿里巴巴的原始店铺,需要按店铺名前缀再分平台。
*/
private const TAB_PLATFORMS = [
'taobao-alipay' => ['淘宝', '天猫', '阿里巴巴'],
'taobao-wechat' => ['淘宝', '天猫'],
'pinduoduo' => ['拼多多'],
'jd-transactions' => ['京东'],
'jingxi' => ['京喜'],
'jd-wallet' => ['京东'],
'douyin' => ['抖音'],
'dewu' => ['得物'],
'vipshop' => ['唯品会MP'],
'vipshop-jx' => ['唯品会jx'],
];
/** 页签账单表里可作「业务备注关键词」匹配的列(折算筛选 SQL 用;列不存在时自动跳过)。 */
private const TAB_REMARK_COLUMNS = [
'taobao-alipay' => ['custom_remark', 'business_type', 'remark', 'second_remark'],
'taobao-wechat' => ['custom_remark', 'remark'],
'pinduoduo' => ['custom_remark', 'business_type', 'remark'],
'dewu' => ['custom_remark', 'business_type', 'order_type', 'channel'],
'jd-transactions' => ['custom_remark'],
'jingxi' => ['custom_remark'],
'jd-wallet' => ['custom_remark'],
'douyin' => [],
'vipshop' => [],
'vipshop-jx' => [],
];
/** 页签账单表里可作「商品名称关键词」匹配的列。 */
private const TAB_GOODS_COLUMNS = [
'taobao-alipay' => ['goods_name'],
'dewu' => ['goods_name'],
];
/** 店铺名前缀可识别的平台(顺序敏感:先长后短)。 */
private const STORE_PREFIX_PLATFORMS = [
'唯品会JX' => '唯品会jx',
'唯品会MP' => '唯品会MP',
'阿里巴巴' => '阿里巴巴',
// 1688 系店铺:新佰广1688 / 1688xxx 等店铺名不带「阿里巴巴」前缀,
// 按平台口径归到阿里巴巴,账单项目与阿里巴巴一致(阿里-xxx 仍走上面的前缀)。
'1688' => '阿里巴巴',
'新佰广' => '阿里巴巴',
'拼多多' => '拼多多',
'天猫' => '天猫',
'淘宝' => '淘宝',
'抖音' => '抖音',
'得物' => '得物',
'京喜' => '京喜',
'京东' => '京东',
];
/**
* @return array<string,array<string,string>> [platform => [bill_item => ledger_remark]]
*/
public function map(): array
{
return Cache::rememberForever(self::CACHE_KEY, function (): array {
try {
if (! Schema::hasTable('ec_account_ledger_remarks')) {
return [];
}
$map = [];
foreach (\Illuminate\Support\Facades\DB::table('ec_account_ledger_remarks')
->select(['platform', 'bill_item', 'ledger_remark'])
->get() as $row) {
$platform = trim((string) $row->platform);
$item = trim((string) $row->bill_item);
if ($platform === '' || $item === '') {
continue;
}
$map[$platform][$item] = (string) $row->ledger_remark;
}
return $map;
} catch (Throwable) {
return [];
}
});
}
public function forget(): void
{
Cache::forget(self::CACHE_KEY);
}
/** 页签默认平台。 */
public function defaultPlatform(string $tab): ?string
{
return self::TAB_PLATFORM[$tab] ?? null;
}
/** 页签涉及的平台(筛选下拉与账单项目翻译共用)。 */
public function platformsForTab(string $tab): array
{
if (isset(self::TAB_PLATFORMS[$tab])) {
return self::TAB_PLATFORMS[$tab];
}
$default = $this->defaultPlatform($tab);
return $default === null ? [] : [$default];
}
/** 平台 → 店铺名前缀(一个平台可能对应多个前缀)。 */
private function prefixesForPlatform(string $platform): array
{
$prefixes = [];
foreach (self::STORE_PREFIX_PLATFORMS as $prefix => $candidate) {
if ($candidate === $platform) {
$prefixes[] = $prefix;
}
}
return $prefixes;
}
/** 页签可选的「总账备注」(该页签涉及平台的去重并集)。 */
public function remarksForTab(string $tab): array
{
$map = $this->map();
$remarks = [];
$rulesByPlatform = $this->accountMappingRulesByPlatform();
foreach ($this->platformsForTab($tab) as $platform) {
foreach (array_values($map[$platform] ?? []) as $remark) {
$remark = trim((string) $remark);
if ($remark !== '') {
$remarks[$remark] = true;
}
}
// 「财务规则 › 科目映射表」里的财务备注同样作为候选,
// 否则新加的备注要等总账备注映射表同步后才能在下拉里选到。
foreach ($rulesByPlatform[$this->normalizeKey($platform)] ?? [] as $rules) {
foreach ($rules as $rule) {
$memo = trim((string) ($rule['memo'] ?? ''));
if ($memo !== '') {
$remarks[$memo] = true;
}
}
}
}
$list = array_keys($remarks);
sort($list, SORT_NATURAL | SORT_FLAG_CASE);
return $list;
}
/**
* 「总账备注」→ 按平台分组的账单项目集合。
*
* 单平台页签只有一组、且不做店铺名前缀限制,可直接 whereIn(bill_item, items);
* 多平台页签(淘系支付宝 / 淘系微信)必须连店铺名前缀一起过滤,才能与页面上
* 按「店铺名前缀 → 平台 + 账单项目」逐行取到的总账备注完全一致。
*
* @return list<array{platform:string,prefixes:list<string>,excludePrefixes:list<string>,items:list<string>,isDefault:bool}>
*/
public function billItemGroupsForTab(string $tab, ?string $remark): array
{
$want = trim((string) $remark);
if ($want === '') {
return [];
}
$map = $this->map();
$platforms = $this->platformsForTab($tab);
$single = count($platforms) <= 1;
$default = $this->defaultPlatform($tab);
$known = array_keys(self::STORE_PREFIX_PLATFORMS);
$rulesByPlatform = $this->accountMappingRulesByPlatform();
$remarkColumns = self::TAB_REMARK_COLUMNS[$tab] ?? [];
$goodsColumns = self::TAB_GOODS_COLUMNS[$tab] ?? [];
$groups = [];
foreach ($platforms as $platform) {
$platformKey = $this->normalizeKey($platform);
$items = [];
$conditions = [];
$this->fillPlatformPlan($platform, $platformKey, $want, $rulesByPlatform, $map, $items, $conditions);
if ($items === [] && $conditions === []) {
continue;
}
sort($items);
$prefixes = $single ? [] : $this->prefixesForPlatform($platform);
$isDefault = ! $single && $platform === $default;
$groups[] = [
'platform' => $platform,
// 单平台页签不做店铺名前缀限制(页签本身就是该平台口径)。
'prefixes' => $prefixes,
// 默认平台还要包含「没有已知平台前缀」的店铺。
'excludePrefixes' => $isDefault ? array_values(array_diff($known, $prefixes)) : [],
'items' => $items,
// 同一账单项目对应多条规则时,用关键词条件折算(LedgerRemarkFilter 负责拼 SQL)
'conditions' => $conditions,
'remarkColumns' => $remarkColumns,
'goodsColumns' => $goodsColumns,
'isDefault' => $isDefault,
];
}
return $groups;
}
/**
* 单个平台的「总账备注」折算素材:直接项目 + 关键词条件(账单明细等多分支页面用)。
*
* @return array{items:list<string>,conditions:list<array<string,mixed>>}
*/
public function ledgerRemarkPlanForPlatform(string $platform, string $remark): array
{
$want = trim($remark);
if ($want === '') {
return ['items' => [], 'conditions' => []];
}
$items = [];
$conditions = [];
$this->fillPlatformPlan(
$platform,
$this->normalizeKey($platform),
$want,
$this->accountMappingRulesByPlatform(),
$this->map(),
$items,
$conditions,
);
return ['items' => $items, 'conditions' => $conditions];
}
/**
* 单个平台的折算逻辑:科目映射表优先(按关键词区分),没覆盖的项目回落老「总账备注映射表」。
*
* @param array<string,array<string,list<array<string,mixed>>>> $rulesByPlatform
* @param array<string,array<string,string>> $map
* @param list<string> $items
* @param list<array<string,mixed>> $conditions
*/
private function fillPlatformPlan(
string $platform,
string $platformKey,
string $want,
array $rulesByPlatform,
array $map,
array &$items,
array &$conditions,
): void {
foreach ($rulesByPlatform[$platformKey] ?? [] as $item => $rules) {
if (count($rules) === 1) {
if (trim((string) ($rules[0]['memo'] ?? '')) === $want) {
$items[] = (string) $item;
}
continue;
}
$keywordConditions = [];
$fallback = null;
foreach ($rules as $rule) {
$memo = trim((string) ($rule['memo'] ?? ''));
$goods = trim((string) ($rule['goods'] ?? ''));
if ($memo === '' && $goods === '') {
$fallback ??= $rule;
continue;
}
$keywordConditions[] = ['memo' => $memo, 'goods' => $goods];
}
$fallback ??= $rules[0];
foreach ($keywordConditions as $condition) {
if ($condition['memo'] !== '' && $condition['memo'] === $want) {
$conditions[] = [
'item' => (string) $item,
'memo' => $condition['memo'],
'goods' => $condition['goods'],
'negate' => false,
'others' => [],
];
}
}
if (trim((string) ($fallback['memo'] ?? '')) === $want) {
// 兜底规则:命不中任何关键词的行才归它
$conditions[] = [
'item' => (string) $item,
'memo' => '',
'goods' => '',
'negate' => true,
'others' => $keywordConditions,
];
}
}
foreach ($map[$platform] ?? [] as $item => $value) {
if (isset($rulesByPlatform[$platformKey][$item])) {
continue;
}
if (trim((string) $value) === $want) {
$items[] = (string) $item;
}
}
$items = array_values(array_unique($items));
sort($items);
}
/**
* 科目映射表按「平台 → 账单项目 → 规则」预索引(平台名做大小写/空格归一)。
*
* @return array<string,array<string,list<array<string,mixed>>>>
*/
private function accountMappingRulesByPlatform(): array
{
$index = [];
try {
foreach (app(AccountMappingStore::class)->all() as $rule) {
if (! is_array($rule)) {
continue;
}
$platform = trim((string) ($rule['platform'] ?? ''));
$item = trim((string) ($rule['item'] ?? ''));
if ($platform === '' || $item === '') {
continue;
}
$index[$this->normalizeKey($platform)][$item][] = $rule;
}
} catch (Throwable) {
return [];
}
return $index;
}
/** 「总账备注」→ 该页签全部相关账单项目(多平台页签为各平台并集)。 */
public function billItemsForTab(string $tab, ?string $remark): array
{
$items = [];
foreach ($this->billItemGroupsForTab($tab, $remark) as $group) {
foreach ($group['items'] as $item) {
$items[$item] = true;
}
}
$list = array_keys($items);
sort($list);
return $list;
}
/** 解析某一行账单所属平台:优先店铺名前缀,其次页签默认。 */
public function platform(?string $storeName, ?string $tabPlatform): ?string
{
$name = trim((string) $storeName);
if ($name !== '') {
foreach (self::STORE_PREFIX_PLATFORMS as $prefix => $platform) {
if (str_starts_with($name, $prefix)) {
return $platform;
}
}
}
return $tabPlatform;
}
/**
* 查总账备注;没有匹配返回 null。
*
* 取值顺序:
* ①「财务规则 › 科目映射表」里同平台同账单项目的「财务备注」(匹配器按业务备注/商品名称关键词取最具体的一条)
* ② 上面没匹到时,回落到本表(账单项目 → 总账备注)原有的口径
*/
public function remark(
?string $storeName,
?string $tabPlatform,
?string $billItem,
array $businessRemarks = [],
string $goodsName = '',
): ?string
{
$item = trim((string) $billItem);
if ($item === '') {
return null;
}
$platforms = [$this->platform($storeName, $tabPlatform), $tabPlatform];
foreach ($platforms as $platform) {
if ($platform === null || $platform === '') {
continue;
}
$fromRule = $this->accountMappingRemark($platform, $item, $businessRemarks, $goodsName);
if ($fromRule !== null) {
return $fromRule;
}
}
$map = $this->map();
if ($map === []) {
return null;
}
foreach ($platforms as $platform) {
if ($platform === null || $platform === '') {
continue;
}
// 先按原样匹配,再按「去掉 wx 前缀」的账单项目匹配(淘系部分行带 wx 前缀)
foreach ($this->itemLookupKeys($item) as $candidate) {
if (isset($map[$platform][$candidate])) {
return $map[$platform][$candidate];
}
}
}
return null;
}
/** 科目映射表按「平台 + 账单项目」预索引,避免逐行扫全部规则。 */
private function accountMappingIndex(): array
{
if ($this->accountMappingIndex !== null) {
return $this->accountMappingIndex;
}
$index = [];
try {
foreach (app(AccountMappingStore::class)->all() as $rule) {
if (! is_array($rule)) {
continue;
}
$platform = trim((string) ($rule['platform'] ?? ''));
$item = trim((string) ($rule['item'] ?? ''));
if ($platform === '' || $item === '') {
continue;
}
// 平台/账单项目做大小写与空格归一:映射表里写的是「唯品会JX」,页签内部键是「唯品会jx」
$index[$this->normalizeKey($platform)][$this->normalizeKey($item)][] = $rule;
}
} catch (Throwable) {
$index = [];
}
return $this->accountMappingIndex = $index;
}
/** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 null(由调用方回落)。 */
private function accountMappingRemark(string $platform, string $item, array $businessRemarks, string $goodsName): ?string
{
$index = $this->accountMappingIndex();
$platformKey = $this->normalizeKey($platform);
// 先按原样匹配,再按「去掉 wx 前缀」的账单项目匹配(科目映射表里部分项目不带 wx 前缀)
foreach ($this->itemLookupKeys($item) as $candidate) {
$rules = $index[$platformKey][$this->normalizeKey($candidate)] ?? [];
if ($rules === []) {
continue;
}
if (count($rules) === 1) {
$remark = trim((string) ($rules[0]['memo'] ?? ''));
if ($remark !== '') {
return $remark;
}
continue;
}
// 同一账单项目有多条规则时,用映射表自己的匹配器(按商品名称/业务备注关键词取最具体的)
try {
$result = app(AccountMappingMatcher::class)->match($platform, $candidate, $businessRemarks, $goodsName);
} catch (Throwable) {
$result = null;
}
$remark = trim((string) ($result['rule']['memo'] ?? ''));
if ($remark !== '') {
return $remark;
}
}
return null;
}
/**
* 账单项目的查找候选:原样优先,其次去掉 wx 前缀。
*
* 淘系同一笔费用在不同页签/导出里会出现带 wx 前缀(wx基础软件服务费)与不带前缀(基础软件服务费)两种写法,
* 按口径应当是同一个总账备注;去掉前缀后再查一次即可命中同一批规则。
*
* @return list<string>
*/
private function itemLookupKeys(string $item): array
{
$item = trim($item);
$keys = [$item];
if ($item !== '' && stripos($item, 'wx') === 0) {
$withoutPrefix = ltrim(mb_substr($item, 2));
if ($withoutPrefix !== '') {
$keys[] = $withoutPrefix;
}
}
return array_values(array_unique($keys));
}
private function normalizeKey(string $value): string
{
return mb_strtolower(preg_replace('/\s+/u', '', $value) ?? $value);
}
/**
* 给一行账单补上 payload.ledger_remark(内部字段名 ledger_remark)。
*
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
public function decorate(array $row, string $tab): array
{
$tabPlatform = $this->defaultPlatform($tab);
$remark = $this->remark(
$row['store_name'] ?? null,
$tabPlatform,
$this->mappingItem($row, $tab),
$this->businessRemarks($row),
$this->goodsName($row),
);
if ($remark === null) {
return $row;
}
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
$payload['ledger_remark'] = $remark;
$row['payload'] = $payload;
// 淘系支付宝页签按顶层字段取值,这里同时补一份,页面与导出共用。
$row['ledger_remark'] = $remark;
return $row;
}
/**
* 取映射用的「账单项目」。
*
* 唯品会JX 的科目映射表(财务规则 › 科目映射表)是按页面「账单项目」列维护的
* (处罚款 / 客服特殊退款-仅退款 / 盘盈盘亏 …,对应 payload.second_remark),
* 所以这一页签用行内账单项目去查规则,其余页签仍用账单项目列(bill_item)。
*/
private function mappingItem(array $row, string $tab): ?string
{
if ($tab === 'vipshop-jx') {
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
$item = trim((string) ($payload['second_remark'] ?? ''));
if ($item !== '') {
return $item;
}
}
return $row['bill_item'] ?? null;
}
/**
* 账单上的业务备注(用于科目映射表的「业务备注关键词」条件)。
*
* @param array<string,mixed> $row
* @return list<string>
*/
private function businessRemarks(array $row): array
{
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
$values = [
$row['custom_remark'] ?? null,
$row['finance_remark'] ?? null,
// 各平台账单表的业务字段(得物:channel=退货退款订单 / business_type / order_type)
$row['channel'] ?? null,
$row['business_type'] ?? null,
$row['order_type'] ?? null,
$payload['second_remark'] ?? null,
$payload['business_description'] ?? null,
$payload['move_summary'] ?? null,
$payload['settle_remark'] ?? null,
$payload['tx_remark'] ?? null,
$payload['cost_name'] ?? null,
$payload['channel'] ?? null,
$payload['business_type'] ?? null,
$payload['order_type'] ?? null,
];
$remarks = [];
foreach ($values as $value) {
$text = trim((string) $value);
if ($text !== '') {
$remarks[] = $text;
}
}
return $remarks;
}
/** 商品名称(用于科目映射表的「商品名称关键词」条件)。 */
private function goodsName(array $row): string
{
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
foreach (['goods_name', 'product_name', 'goods_title'] as $key) {
$value = trim((string) ($payload[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* @param iterable<int,array<string,mixed>> $rows
* @return \Generator<int,array<string,mixed>>
*/
public function decorateAll(iterable $rows, string $tab): \Generator
{
foreach ($rows as $row) {
yield is_array($row) ? $this->decorate($row, $tab) : $row;
}
}
}