'淘宝', '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> [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,excludePrefixes:list,items:list,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,conditions:list>} */ 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>>> $rulesByPlatform * @param array> $map * @param list $items * @param list> $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>>> */ 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 { $rules = $this->accountMappingOverrideRules() ?? app(AccountMappingStore::class)->all(); foreach ($rules 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; } /** * 本地模拟映射覆盖:仅在显式设置 ACCOUNT_MAPPING_OVERRIDE_FILE 时启用。 * 正常页面和定时任务不设置该变量,继续读取共享系统的科目映射表。 * * @return list>|null */ private function accountMappingOverrideRules(): ?array { $path = trim((string) (getenv('ACCOUNT_MAPPING_OVERRIDE_FILE') ?: '')); if ($path === '' || ! is_file($path)) { return null; } $decoded = json_decode((string) file_get_contents($path), true); if (! is_array($decoded) || ! array_is_list($decoded)) { throw new \RuntimeException('本地科目映射覆盖文件格式无效:' . $path); } return $decoded; } /** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 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, $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 $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, // 各平台账单表的业务字段(得物: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> $rows * @return \Generator> */ public function decorateAll(iterable $rows, string $tab): \Generator { foreach ($rows as $row) { yield is_array($row) ? $this->decorate($row, $tab) : $row; } } }