业绩明细新增费用项并按科目映射费用备注归集

This commit is contained in:
qiuxiang 2026-10-19 18:28:48 +08:00
parent 56909bd669
commit 366dcc1da7
6 changed files with 328 additions and 45 deletions

View File

@ -11,7 +11,6 @@ use App\Business\JstOrderLabelPlantingRules;
use App\Business\PlantingOrderLabelLookup;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use Throwable;
@ -131,6 +130,12 @@ class SyncStoreReceiptOrderMatches extends Command
'退款',
];
/** 「费用项」排除的科目映射费用备注:这两类不是费用。 */
private const FEE_ITEM_EXCLUDED_MEMOS = [
'收入',
'往来',
];
private const TARGET_TABLE = 'store_receipt_order_match_rows';
private const STATUS_MATCHED = '已匹配';
@ -324,18 +329,6 @@ class SyncStoreReceiptOrderMatches extends Command
$billQuery->whereIn('generation', $generations);
$this->info('活动代:' . count($generations) . ' 个');
}
$billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $refundGroups, $promoItems): void {
LedgerRemarkFilter::applyGroups(
$group,
array_merge($baseGroups, $discountGroups, $offsetGroups, $refundGroups),
'bill_item',
static fn ($sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'),
static fn ($sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'),
);
if ($promoItems !== []) {
$group->orWhereIn('bill_item', $promoItems);
}
});
if ($limit > 0) {
$billQuery->limit($limit);
}
@ -350,6 +343,7 @@ class SyncStoreReceiptOrderMatches extends Command
'discountTotal' => $discountTotal, 'offsetTotal' => $offsetTotal, 'refundTotal' => $refundTotal,
'moneyRemarkRows' => $moneyRemarkRows, 'attachedDiscount' => $attachedDiscount,
'attachedOffset' => $attachedOffset, 'unattachedRows' => $unattachedRows, 'billCount' => $billCount,
'feeItemOrderCount' => $feeItemOrderCount, 'feeMappingGaps' => $feeMappingGaps,
] = $this->aggregateBills(
$this->streamBillRows($billQuery->orderBy($timeColumn)), $source,
$orderColumn, $timeColumn, $amountColumn, $expenseColumn, $realStoreColumn,
@ -376,6 +370,11 @@ class SyncStoreReceiptOrderMatches extends Command
count($plantingCores),
number_format($plantingTotal, 2),
));
$this->line(sprintf(
' 费用项:%d 个订单有费用项,缺失映射 %d 组',
$feeItemOrderCount,
count($feeMappingGaps),
));
$this->line(sprintf(
' 已挂订单行:优惠 %s / 冲减 %s;未挂到订单行:%s(金额 %s,已补成「无收款账单 / 账单无单号」行)',
number_format($attachedDiscount, 2),
@ -728,6 +727,7 @@ class SyncStoreReceiptOrderMatches extends Command
'offset_amount' => ! empty($group['offset_seen']) ? round((float) $group['offset_amount'], 2) : null,
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
'planting_amount' => ! empty($group['planting_seen']) ? $income : null,
'fee_item' => $group['fee_item'] ?? null,
'product_amount' => $productAmount,
'paid_amount' => $paidAmount,
'difference' => $difference,
@ -804,6 +804,34 @@ class SyncStoreReceiptOrderMatches extends Command
return self::FAILURE;
}
if ($feeMappingGaps !== []) {
ksort($feeMappingGaps);
$gapPath = $this->writeFeeMappingGapReport($month, (string) $source['label'], $feeMappingGaps);
$this->error(sprintf(
'费用项映射待补:%s %s 共 %d 组总账备注在科目映射表中没有可用费用备注。',
$month,
(string) $source['label'],
count($feeMappingGaps),
));
foreach (array_slice($feeMappingGaps, 0, 20) as $gap) {
$this->line(sprintf(
' %s / %s → %s(%s,%d 行,金额 %s)',
$gap['platform'],
$gap['bill_item'],
$gap['ledger_remark'],
$gap['reason'],
$gap['rows'],
number_format((float) $gap['amount'], 2),
));
}
if (count($feeMappingGaps) > 20) {
$this->line(' 其余缺失项见清单文件。');
}
if ($gapPath !== null) {
$this->line(' 缺失清单:' . $gapPath);
}
}
// 业绩明细变了,顺手刷新店铺维度报表的销量汇总(销量=这里的订单实付/实收金额)。
// 汇总失败不影响业绩明细本身,只提示一次,避免把已写入的账期数据判成失败。
return self::SUCCESS;
@ -1058,6 +1086,11 @@ class SyncStoreReceiptOrderMatches extends Command
$discountTotal = 0.0;
$offsetTotal = 0.0;
$refundTotal = 0.0;
/** @var array<string,array<string,true>> $feeItemsByKey 门店+订单 维度的费用项集合 */
$feeItemsByKey = [];
/** @var array<string,array<string,mixed>> $feeMappingGaps 科目映射缺失统计 */
$feeMappingGaps = [];
$feeItemOrderCount = 0;
/** @var array<string,int> $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */
$promoRowsByKey = [];
// 本月账单里真正命中「优惠收入 / 冲减收入 / 退款」的行数(重建后自检用:
@ -1069,15 +1102,61 @@ class SyncStoreReceiptOrderMatches extends Command
$billCount++;
$storeName = (string) ($bill->store_name ?? '');
$billItem = (string) ($bill->bill_item ?? '');
// 收入 / 优惠 / 冲减 / 退款统一按科目映射表的当前财务备注判桶;
// 只有当前科目映射表没有规则时,remark() 才回落到旧总账备注表。
$remark = $remarkMap->remark(
$businessRemarks = $this->billBusinessRemarks($bill);
$goodsName = $this->billGoodsName($bill);
// 一次匹配同时取财务备注和费用备注;科目映射表没有规则时才回落旧总账备注。
$accountMatch = $remarkMap->accountMappingMatch(
$storeName,
$tabPlatform,
$billItem,
$this->billBusinessRemarks($bill),
$this->billGoodsName($bill),
$businessRemarks,
$goodsName,
);
$accountMemo = trim((string) ($accountMatch['rule']['memo'] ?? ''));
$remark = $accountMemo !== ''
? $accountMemo
: $remarkMap->ledgerRemarkFallback($storeName, $tabPlatform, $billItem);
$feeMemo = trim((string) ($accountMatch['rule']['feeMemo'] ?? ''));
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
$core = self::cleanOrderNo($rawNo);
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
$key = $shopKey . '|' . $core;
$amount = (float) $bill->{$amountColumn}
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
if ($core !== '' && $feeMemo !== ''
&& ! in_array($feeMemo, self::FEE_ITEM_EXCLUDED_MEMOS, true)) {
$feeItemsByKey[$key][$feeMemo] = true;
}
// 科目映射缺失时保留旧总账备注继续跑,同时把待补项记录到 CSV。
if (trim((string) $remark) !== '') {
$gapReason = null;
if ($accountMatch === null) {
$gapReason = '科目映射表无平台+账单项目';
} elseif ($feeMemo === '') {
$gapReason = '费用备注为空';
}
if ($gapReason !== null) {
$platformName = $remarkMap->platform($storeName, $tabPlatform) ?? (string) $source['label'];
$gapKey = $platformName . '|' . $billItem . '|' . $remark . '|' . $gapReason;
$feeMappingGaps[$gapKey] ??= [
'platform' => $platformName,
'bill_item' => $billItem,
'ledger_remark' => (string) $remark,
'reason' => $gapReason,
'rows' => 0,
'amount' => 0.0,
'sample_store' => $storeName,
];
$feeMappingGaps[$gapKey]['rows']++;
$feeMappingGaps[$gapKey]['amount'] += $amount;
}
}
$isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true)
|| in_array($remark, self::OFFSET_REMARKS, true);
$isRefundRemark = in_array($remark, self::REFUND_REMARKS, true);
@ -1096,12 +1175,6 @@ class SyncStoreReceiptOrderMatches extends Command
continue;
}
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
$core = self::cleanOrderNo($rawNo);
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
$key = $shopKey . '|' . $core;
if (! isset($groups[$key])) {
$groups[$key] = [
'platform' => (string) $source['label'],
@ -1124,8 +1197,6 @@ class SyncStoreReceiptOrderMatches extends Command
// 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。
// 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。
$amount = (float) $bill->{$amountColumn}
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
// 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。
// 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。
if ($bucket !== 'income') {
@ -1181,6 +1252,16 @@ class SyncStoreReceiptOrderMatches extends Command
// 只有优惠/冲减、没有收款的订单号不单独成行。
$groups = array_filter($groups, static fn (array $group): bool => (int) $group['payment_count'] > 0);
foreach ($groups as &$group) {
$items = array_keys($feeItemsByKey[$group['shop_key'] . '|' . $group['order_core_no']] ?? []);
sort($items, SORT_NATURAL | SORT_FLAG_CASE);
$group['fee_item'] = $items === [] ? null : implode('、', $items);
if ($items !== []) {
$feeItemOrderCount++;
}
}
unset($group);
// 口径对照:账单口径合计、已挂到订单行的合计、挂不上的行数(账单没有订单号 / 订单没有收款行)。
$attachedDiscount = 0.0;
$attachedOffset = 0.0;
@ -1200,10 +1281,17 @@ class SyncStoreReceiptOrderMatches extends Command
unset($promoOnlyGroups[$key]);
}
}
foreach ($promoOnlyGroups as &$group) {
$items = array_keys($feeItemsByKey[$group['shop_key'] . '|' . $group['order_core_no']] ?? []);
sort($items, SORT_NATURAL | SORT_FLAG_CASE);
$group['fee_item'] = $items === [] ? null : implode('、', $items);
}
unset($group);
return compact(
'groups', 'promoOnlyGroups', 'paymentTotal', 'incomeTotal', 'discountTotal',
'offsetTotal', 'refundTotal', 'moneyRemarkRows', 'attachedDiscount', 'attachedOffset', 'unattachedRows', 'billCount',
'feeItemOrderCount', 'feeMappingGaps',
);
}
@ -1235,6 +1323,7 @@ class SyncStoreReceiptOrderMatches extends Command
'refund_amount' => ! empty($group['refund_seen']) ? round((float) $group['refund_amount'], 2) : null,
// 补行没有订单侧商品金额,种菜金额同样留空;列集合与顺序必须与主行完全一致,批量插入才不会错列。
'planting_amount' => null,
'fee_item' => $group['fee_item'] ?? null,
'product_amount' => null,
'paid_amount' => null,
'difference' => null,
@ -1406,6 +1495,43 @@ class SyncStoreReceiptOrderMatches extends Command
return (string) config('performance_details.difference_basis', 'paid') === 'product' ? 'product' : 'paid';
}
/**
* 写出「科目映射表待补」清单,供财务补规则后重新同步。
*
* @param array<string,array<string,mixed>> $gaps
*/
private function writeFeeMappingGapReport(string $month, string $platform, array $gaps): ?string
{
$dir = storage_path('app/performance-fee-item-gaps');
if (! is_dir($dir) && ! @mkdir($dir, 0775, true) && ! is_dir($dir)) {
return null;
}
$safePlatform = preg_replace('/[^\p{L}\p{N}_-]+/u', '_', $platform) ?: 'platform';
$path = $dir . DIRECTORY_SEPARATOR . $month . '_' . $safePlatform . '_' . now()->format('Ymd_His') . '.csv';
$handle = @fopen($path, 'wb');
if ($handle === false) {
return null;
}
fwrite($handle, "\xEF\xBB\xBF");
fputcsv($handle, ['平台', '账单项目', '总账备注', '缺失原因', '账单行数', '金额', '示例店铺']);
foreach ($gaps as $gap) {
fputcsv($handle, [
$gap['platform'],
$gap['bill_item'],
$gap['ledger_remark'],
$gap['reason'],
$gap['rows'],
number_format((float) $gap['amount'], 2, '.', ''),
$gap['sample_store'],
]);
}
fclose($handle);
return $path;
}
/**
* 按「店铺 + 订单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
*

View File

@ -1798,6 +1798,7 @@ class Pages extends Controller
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
'refund_amount' => ['refund_amount', '退款金额', 'money'],
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
'fee_item' => ['fee_item', '费用项', 'text'],
'product_amount' => ['product_amount', '商品金额', 'money'],
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
'difference' => ['difference', '差额', 'money'],

View File

@ -822,7 +822,7 @@ $navigation = [
'directory' => [
'icon' => 'receipt_long',
'status' => '共享镜像',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注)、差额=直营补贴款(待开票结算)(仅京喜账单·京喜自营工厂店负差额且无退款(出类拔萃等 10 家已逐店核验,其余工厂店按实付≈商品金额的供货价特征识别):京东直营补贴款待开票结算时标注)、差额=直营补贴款(订单退款已冲回)(同批店负差额但订单已退款:补贴款已随退款冲回)、差额=订单实付包含商家承担优惠和以旧换新国补(仅京东账单·索菲亚客厅/办公/书房/功能、亿家达全屋、朴美家居店负差额、且该单含国补(聚水潭应付−账单收入>0):差额为商家承担优惠(店铺东券/单品折扣等)与以旧换新国补之和,已用营销对账逐单核实)、差额=订单实付包含商家承担优惠(同店负差额但不含国补的单:差额即商家承担优惠)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)(以上三档等式对带口径标识的行让位给口径兜底)、订单实付含已退款部分(抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注;淘系支付宝/微信负差额:差额=退款合计、或退款合计=实付−收入(收款缺口)、或整单全退(退款合计≈实付)时同样标注(退款取聚水潭已确认记录));都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;「费用项」按科目映射表的费用备注归集,一单多费用时用顿号连接,只展示、不参与收入与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注)、差额=直营补贴款(待开票结算)(仅京喜账单·京喜自营工厂店负差额且无退款(出类拔萃等 10 家已逐店核验,其余工厂店按实付≈商品金额的供货价特征识别):京东直营补贴款待开票结算时标注)、差额=直营补贴款(订单退款已冲回)(同批店负差额但订单已退款:补贴款已随退款冲回)、差额=订单实付包含商家承担优惠和以旧换新国补(仅京东账单·索菲亚客厅/办公/书房/功能、亿家达全屋、朴美家居店负差额、且该单含国补(聚水潭应付−账单收入>0):差额为商家承担优惠(店铺东券/单品折扣等)与以旧换新国补之和,已用营销对账逐单核实)、差额=订单实付包含商家承担优惠(同店负差额但不含国补的单:差额即商家承担优惠)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)(以上三档等式对带口径标识的行让位给口径兜底)、订单实付含已退款部分(抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注;淘系支付宝/微信负差额:差额=退款合计、或退款合计=实付−收入(收款缺口)、或整单全退(退款合计≈实付)时同样标注(退款取聚水潭已确认记录));都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],
@ -834,6 +834,7 @@ $navigation = [
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
['name' => 'fee_item', 'label' => '费用项'],
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
['name' => 'paid_amount', 'label' => '订单实付/实收金额', 'kind' => 'number'],
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「费用项」列:
* 按科目映射表的 fee_memo(费用备注)归集到订单,一单多费用时用顿号连接。
* 只用于展示,不参与收入、优惠、冲减、退款和差额计算。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->string('fee_item', 191)->nullable()->after('planting_amount');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('fee_item');
});
}
};

View File

@ -405,6 +405,110 @@ class AccountLedgerRemarkMap
return $tabPlatform;
}
/**
* 返回命中的科目映射规则和费用备注,供业绩明细等需要保留规则详情的地方使用。
*
* 查找顺序与 remark() 一致:先按店铺平台匹配,再按页签平台兜底;
* 每个平台先取原始账单项目,bill_item 以 wx 开头时再取去掉 wx 的候选。
*
* @param array<int,string> $businessRemarks
* @return array{platform:string,bill_item:string,rule:array<string,mixed>,matched:bool,reason:string}|null
*/
public function accountMappingMatch(
?string $storeName,
?string $tabPlatform,
?string $billItem,
array $businessRemarks = [],
string $goodsName = '',
): ?array {
$item = trim((string) $billItem);
if ($item === '') {
return null;
}
$platforms = array_values(array_unique(array_filter(
[$this->platform($storeName, $tabPlatform), $tabPlatform],
static fn (?string $platform): bool => $platform !== null && trim($platform) !== '',
)));
$index = $this->accountMappingIndex();
foreach ($platforms as $platform) {
$platformKey = $this->normalizeKey($platform);
foreach ($this->itemLookupKeys($item) as $candidate) {
$rules = $index[$platformKey][$this->normalizeKey($candidate)] ?? [];
if ($rules === []) {
continue;
}
if (count($rules) === 1) {
return [
'platform' => $platform,
'bill_item' => $candidate,
'rule' => $rules[0],
'matched' => true,
'reason' => '唯一规则',
];
}
try {
$result = app(AccountMappingMatcher::class)->match(
$platform,
$candidate,
$businessRemarks,
$goodsName,
);
} catch (Throwable) {
$result = null;
}
if (is_array($result) && isset($result['rule'])) {
return [
'platform' => $platform,
'bill_item' => $candidate,
'rule' => $result['rule'],
'matched' => (bool) ($result['matched'] ?? false),
'reason' => (string) ($result['reason'] ?? ''),
];
}
}
}
return null;
}
/**
* 旧「总账备注映射表」的兜底值;已按平台和 wx 前缀规则查找。
*/
public function ledgerRemarkFallback(
?string $storeName,
?string $tabPlatform,
?string $billItem,
): ?string {
$item = trim((string) $billItem);
if ($item === '') {
return null;
}
$map = $this->map();
if ($map === []) {
return null;
}
$platforms = array_values(array_unique(array_filter(
[$this->platform($storeName, $tabPlatform), $tabPlatform],
static fn (?string $platform): bool => $platform !== null && trim($platform) !== '',
)));
foreach ($platforms as $platform) {
foreach ($this->itemLookupKeys($item) as $candidate) {
if (isset($map[$platform][$candidate])) {
return (string) $map[$platform][$candidate];
}
}
}
return null;
}
/**
* 查总账备注;没有匹配返回 null。
*
@ -437,24 +541,7 @@ class AccountLedgerRemarkMap
}
}
$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;
return $this->ledgerRemarkFallback($storeName, $tabPlatform, $item);
}
/** 科目映射表按「平台 + 账单项目」预索引,避免逐行扫全部规则。 */

View File

@ -3,6 +3,7 @@
namespace Tests\Feature\Business;
use Illuminate\Support\Facades\File;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Modules\EcommerceFinance\Services\AccountMappingMatcher;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
@ -123,6 +124,28 @@ class AccountMappingMatcherTest extends FeatureTestCase
$this->assertFalse($miss['matched']);
}
public function test_performance_fee_item_uses_the_matched_rule_fee_memo(): void
{
$rule = $this->rule([
'platform' => '京东',
'item' => '平台服务费',
'memo' => '平台服务费',
'feeMemo' => '平台服务费',
'category' => '费用',
]);
$matched = app(AccountLedgerRemarkMap::class)->accountMappingMatch(
'京东-测试店铺',
'jd-transactions',
'平台服务费',
);
$this->assertNotNull($matched);
$this->assertTrue($matched['matched']);
$this->assertSame($rule['id'], $matched['rule']['id']);
$this->assertSame('平台服务费', $matched['rule']['feeMemo']);
}
/** 校验:同项目、条件不同 = 正常;条件相同 = 才算重复 */
public function test_validator_flags_only_same_condition_pairs(): void
{