业绩明细删除费用项列及相关归集逻辑
This commit is contained in:
parent
4e174963e8
commit
cdabffb3a6
@ -11,6 +11,7 @@ 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;
|
||||
|
||||
@ -130,12 +131,6 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'退款',
|
||||
];
|
||||
|
||||
/** 「费用项」排除的科目映射费用备注:这两类不是费用。 */
|
||||
private const FEE_ITEM_EXCLUDED_MEMOS = [
|
||||
'收入',
|
||||
'往来',
|
||||
];
|
||||
|
||||
/** 这些收款账户不进入业绩明细,收、支都不取。 */
|
||||
private const EXCLUDED_TARGET_SOURCES = [
|
||||
'jd-wallet',
|
||||
@ -352,6 +347,18 @@ 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);
|
||||
}
|
||||
@ -366,7 +373,6 @@ 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,
|
||||
@ -393,11 +399,6 @@ 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),
|
||||
@ -750,7 +751,6 @@ 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,
|
||||
@ -827,34 +827,6 @@ 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;
|
||||
@ -1109,11 +1081,6 @@ 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 = [];
|
||||
// 本月账单里真正命中「优惠收入 / 冲减收入 / 退款」的行数(重建后自检用:
|
||||
@ -1125,61 +1092,15 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$billCount++;
|
||||
$storeName = (string) ($bill->store_name ?? '');
|
||||
$billItem = (string) ($bill->bill_item ?? '');
|
||||
$businessRemarks = $this->billBusinessRemarks($bill);
|
||||
$goodsName = $this->billGoodsName($bill);
|
||||
// 一次匹配同时取财务备注和费用备注;科目映射表没有规则时才回落旧总账备注。
|
||||
$accountMatch = $remarkMap->accountMappingMatch(
|
||||
// 收入 / 优惠 / 冲减 / 退款统一按科目映射表的当前财务备注判桶;
|
||||
// 只有当前科目映射表没有规则时,remark() 才回落到旧总账备注表。
|
||||
$remark = $remarkMap->remark(
|
||||
$storeName,
|
||||
$tabPlatform,
|
||||
$billItem,
|
||||
$businessRemarks,
|
||||
$goodsName,
|
||||
$this->billBusinessRemarks($bill),
|
||||
$this->billGoodsName($bill),
|
||||
);
|
||||
$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);
|
||||
@ -1198,6 +1119,12 @@ 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'],
|
||||
@ -1220,6 +1147,8 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
// 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。
|
||||
// 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。
|
||||
$amount = (float) $bill->{$amountColumn}
|
||||
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
|
||||
// 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。
|
||||
// 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。
|
||||
if ($bucket !== 'income') {
|
||||
@ -1275,16 +1204,6 @@ 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;
|
||||
@ -1304,17 +1223,10 @@ 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',
|
||||
);
|
||||
}
|
||||
|
||||
@ -1346,7 +1258,6 @@ 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,
|
||||
@ -1518,43 +1429,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「店铺 + 订单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
|
||||
*
|
||||
|
||||
@ -1809,7 +1809,6 @@ 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'],
|
||||
|
||||
@ -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,7 +834,6 @@ $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'],
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 撤销「费用项」功能:删除业绩明细镜像中的 fee_item 列。
|
||||
*/
|
||||
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')
|
||||
|| ! $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');
|
||||
});
|
||||
}
|
||||
|
||||
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->string('fee_item', 191)->nullable()->after('planting_amount');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -405,110 +405,6 @@ 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。
|
||||
*
|
||||
@ -541,7 +437,24 @@ class AccountLedgerRemarkMap
|
||||
}
|
||||
}
|
||||
|
||||
return $this->ledgerRemarkFallback($storeName, $tabPlatform, $item);
|
||||
$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;
|
||||
}
|
||||
|
||||
/** 科目映射表按「平台 + 账单项目」预索引,避免逐行扫全部规则。 */
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
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;
|
||||
@ -124,28 +123,6 @@ 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
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user