diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index c5301ddc..80118e47 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -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> $feeItemsByKey 门店+订单 维度的费用项集合 */ + $feeItemsByKey = []; + /** @var array> $feeMappingGaps 科目映射缺失统计 */ + $feeMappingGaps = []; + $feeItemOrderCount = 0; /** @var array $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> $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; + } + /** * 按「店铺 + 订单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。 * diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 39ac2b4d..12de7c90 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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'], diff --git a/config/business_navigation.php b/config/business_navigation.php index b10868d3..b4cc54a8 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -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'], diff --git a/database/migrations/2026_10_19_190000_add_fee_item_to_store_receipt_order_match_rows.php b/database/migrations/2026_10_19_190000_add_fee_item_to_store_receipt_order_match_rows.php new file mode 100644 index 00000000..602e9091 --- /dev/null +++ b/database/migrations/2026_10_19_190000_add_fee_item_to_store_receipt_order_match_rows.php @@ -0,0 +1,45 @@ +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'); + }); + } +}; diff --git a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php index fd33d349..21e4e043 100644 --- a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php +++ b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php @@ -405,6 +405,110 @@ class AccountLedgerRemarkMap return $tabPlatform; } + /** + * 返回命中的科目映射规则和费用备注,供业绩明细等需要保留规则详情的地方使用。 + * + * 查找顺序与 remark() 一致:先按店铺平台匹配,再按页签平台兜底; + * 每个平台先取原始账单项目,bill_item 以 wx 开头时再取去掉 wx 的候选。 + * + * @param array $businessRemarks + * @return array{platform:string,bill_item:string,rule:array,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); } /** 科目映射表按「平台 + 账单项目」预索引,避免逐行扫全部规则。 */ diff --git a/tests/Feature/Business/AccountMappingMatcherTest.php b/tests/Feature/Business/AccountMappingMatcherTest.php index 7d350199..c717a3b0 100644 --- a/tests/Feature/Business/AccountMappingMatcherTest.php +++ b/tests/Feature/Business/AccountMappingMatcherTest.php @@ -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 {