'淘宝', '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'], ]; /** 店铺名前缀可识别的平台(顺序敏感:先长后短)。 */ private const STORE_PREFIX_PLATFORMS = [ '唯品会JX' => '唯品会jx', '唯品会MP' => '唯品会MP', '阿里巴巴' => '阿里巴巴', // 1688 系店铺:新佰广1688 / 1688xxx 等店铺名不带「阿里巴巴」前缀, // 按平台口径归到阿里巴巴,账单项目与阿里巴巴一致(阿里-xxx 仍走上面的前缀)。 '1688' => '阿里巴巴', '新佰广' => '阿里巴巴', '拼多多' => '拼多多', '天猫' => '天猫', '淘宝' => '淘宝', '抖音' => '抖音', '得物' => '得物', '京喜' => '京喜', '京东' => '京东', ]; /** * @return array> [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 = []; foreach ($this->platformsForTab($tab) as $platform) { foreach (array_values($map[$platform] ?? []) as $remark) { $remark = trim((string) $remark); if ($remark !== '') { $remarks[$remark] = true; } } } $list = array_keys($remarks); sort($list, SORT_NATURAL | SORT_FLAG_CASE); return $list; } /** * 「总账备注」→ 按平台分组的账单项目集合。 * * 单平台页签只有一组、且不做店铺名前缀限制,可直接 whereIn(bill_item, items); * 多平台页签(淘系支付宝 / 淘系微信)必须连店铺名前缀一起过滤,才能与页面上 * 按「店铺名前缀 → 平台 + 账单项目」逐行取到的总账备注完全一致。 * * @return list,excludePrefixes:list,items:list,isDefault:bool}> */ public function billItemGroupsForTab(string $tab, ?string $remark): array { $want = trim((string) $remark); if ($want === '') { return []; } $map = $this->map(); if ($map === []) { return []; } $platforms = $this->platformsForTab($tab); $single = count($platforms) <= 1; $default = $this->defaultPlatform($tab); $known = array_keys(self::STORE_PREFIX_PLATFORMS); $groups = []; foreach ($platforms as $platform) { $items = []; foreach ($map[$platform] ?? [] as $item => $value) { if (trim((string) $value) === $want) { $items[] = (string) $item; } } if ($items === []) { 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, 'isDefault' => $isDefault, ]; } return $groups; } /** 「总账备注」→ 该页签全部相关账单项目(多平台页签为各平台并集)。 */ 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 */ 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 $row * @return array */ public function decorate(array $row, string $tab): array { $tabPlatform = $this->defaultPlatform($tab); $remark = $this->remark( $row['store_name'] ?? null, $tabPlatform, $row['bill_item'] ?? null, $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; } /** * 账单上的业务备注(用于科目映射表的「业务备注关键词」条件)。 * * @param array $row * @return list */ private function businessRemarks(array $row): array { $payload = is_array($row['payload'] ?? null) ? $row['payload'] : []; $values = [ $row['custom_remark'] ?? null, $row['finance_remark'] ?? 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, ]; $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> $rows * @return \Generator> */ public function decorateAll(iterable $rows, string $tab): \Generator { foreach ($rows as $row) { yield is_array($row) ? $this->decorate($row, $tab) : $row; } } }