CaiWuXiTong/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php
yueyang f7197b4976 财务分析新增开票/不开票费用,并补齐违约赔付预览 (#480)
## 任务占用与边界

- 任务名称:财务分析开票费用与业绩明细违约赔付预览
- 实际开发者:yueyang
- 任务来源/团队分工:负责人直接指定
- 目标结果:财务经营分析表按科目映射表拆分开票费用/不开票费用;业绩明细增加违约赔付(冲减收入)列并按指定顺序展示。
- 预计修改路径:业务 Pages Controller、财务分析视图、账单明细数据服务、业绩明细配置与相关测试。
- 将写入的公共契约:财务分析费用聚合、默认补偿预览命令、业绩明细列配置。
- 仅复用、明确不修改的公共契约:store_receipt_order_match_rows、ec_account_mapping_rules。
- 数据源、表/视图和字段:店铺收款账单镜像、store_receipt_order_match_rows、store_receipt_product_cost_rows、ec_account_mapping_rules。
- 明确不修改的范围:账单抓取与上游同步链路。

## 开工碰撞检查

- [x] Codex 已执行 scripts/codex-task-preflight.ps1
- [x] 已检查最新 main 和远程任务分支
- [x] 已搜索主线既有页面、接口和数据服务
- [x] 已将写入和复用契约分开
- [x] 预检列出的残留分支均无开放 PR;当前 2 个开放 PR 与本任务无关,确认无活跃写入冲突

## 实际改动

- 财务经营分析表增加“开票费用”“不开票费用”及合计;财务类型=费用,票据口径 0.06/0.09 归入开票,不开票归入不开票,金额按支出减收入净额。
- 费用聚合并入现有月度缓存,缓存键包含科目映射哈希,映射变化后自动失效。
- 业绩明细增加违约赔付(冲减收入)列,放在冲减金额后。
- CSV 导出列同步更新。

## 验证证据

- 正常流程:2026-07/08/09 实库数据计算成功;2026-09 开票费用 3,896,538.65,不开票费用 81,377.62。
- 实际页面:2026-09 财务经营分析表 438 行,两列与合计正常展示。
- 性能:单月费用聚合 7.0–13.6 秒,PHP 峰值约 65.5 MB;页面结果缓存 5 分钟。
- 自动测试:NavigationTest 目标 2 条通过(35 assertions),StoreReceiptMappingPreviewOverrideTest 2 条通过(5 assertions)。

## 风险检查

- [x] 不涉及数据库迁移或生产数据写入
- [x] 涉及金额口径,已用实库页面和固定总额核对
- [x] 未提交 .env、密码、Token、真实数据或构建产物
- [x] 前端符合 DESIGN.md 黑金表格样式

## 合并前复查

- [x] 已基于最新 main 53bd1dc4 变基
- [x] 完整 diff 自审,无冲突标记
- [x] 目标测试通过
- [x] 负责人已授权 Squash merge 和发布

Reviewed-on: #480
2026-10-23 19:05:35 +08:00

691 lines
24 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 {
$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<array<string,mixed>>|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<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;
}
}
}