diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index 0098afb8..2cd3d619 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -123,6 +123,11 @@ class SyncStoreReceiptOrderMatches extends Command '冲减收入', ]; + /** 「退款金额」列口径:账单的总账备注命中以下取值,单独列示,不参与收入金额与差额计算。 */ + private const REFUND_REMARKS = [ + '退款', + ]; + private const TARGET_TABLE = 'store_receipt_order_match_rows'; private const STATUS_MATCHED = '已匹配'; @@ -205,11 +210,13 @@ class SyncStoreReceiptOrderMatches extends Command $baseGroups = $this->ledgerRemarkGroups($platform); $discountGroups = $this->ledgerRemarkGroups($platform, self::DISCOUNT_REMARKS); $offsetGroups = $this->ledgerRemarkGroups($platform, self::OFFSET_REMARKS); + $refundGroups = $this->ledgerRemarkGroups($platform, self::REFUND_REMARKS); // 总账备注现在优先取科目映射表的财务备注,因此这两列的候选账单项目 - // 也要按科目映射表补一遍,否则「优惠收入 / 冲减收入」永远取不到项目。 + // 也要按科目映射表补一遍,否则「优惠收入 / 冲减收入 / 退款」永远取不到项目。 $promoItems = array_values(array_unique(array_merge( $this->accountMappingItems($platform, self::DISCOUNT_REMARKS), $this->accountMappingItems($platform, self::OFFSET_REMARKS), + $this->accountMappingItems($platform, self::REFUND_REMARKS), ))); // 微信账户页签的账单项目带「wx」前缀(wx限时红包代商家垫付扣回 等), // 科目映射表用的是不带前缀的名字,这里两种写法都带上。 @@ -220,14 +227,22 @@ class SyncStoreReceiptOrderMatches extends Command return self::FAILURE; } $this->info('总账备注命中平台分组:' . count($baseGroups) . ' 组'); - if ($discountGroups === [] && $offsetGroups === [] && $promoItems === []) { - $this->warn('「优惠收入」「冲减收入」在科目映射表与总账备注映射里都没有对应账单项目,两列将全部为空。'); + if ($discountGroups === [] && $offsetGroups === [] && $refundGroups === [] && $promoItems === []) { + $this->warn('「优惠收入」「冲减收入」「退款」在科目映射表与总账备注映射里都没有对应账单项目,三列将全部为空。'); } $select = ['store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn]; // 「冲减金额」按行金额(收入侧 − 支出侧)取数,账单表有支出列时一并取出。 + // 淘系/拼多多/得物的支出列叫 expense_amount,京东系列(京东账单/京喜/京东网银钱包)与抖音叫 expense, + // 两种写法都要认,否则支出侧的退款/扣回会被当成 0。 + $expenseColumn = ''; if (Schema::connection($connection)->hasColumn($billTable, 'expense_amount')) { - $select[] = 'expense_amount'; + $expenseColumn = 'expense_amount'; + } elseif (Schema::connection($connection)->hasColumn($billTable, 'expense')) { + $expenseColumn = 'expense'; + } + if ($expenseColumn !== '') { + $select[] = $expenseColumn; } if ($realStoreColumn !== '') { $select[] = $realStoreColumn; @@ -268,10 +283,10 @@ class SyncStoreReceiptOrderMatches extends Command $billQuery->whereIn('generation', $generations); $this->info('活动代:' . count($generations) . ' 个'); } - $billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $promoItems): void { + $billQuery->where(function ($group) use ($baseGroups, $discountGroups, $offsetGroups, $refundGroups, $promoItems): void { LedgerRemarkFilter::applyGroups( $group, - array_merge($baseGroups, $discountGroups, $offsetGroups), + 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 . '%'), @@ -298,6 +313,7 @@ class SyncStoreReceiptOrderMatches extends Command $incomeTotal = 0.0; $discountTotal = 0.0; $offsetTotal = 0.0; + $refundTotal = 0.0; // 种菜订单号:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号(或的关系)。 $plantingCores = $this->plantingOrderCores(); $plantingTotal = 0.0; @@ -309,6 +325,9 @@ class SyncStoreReceiptOrderMatches extends Command $incomeOverlapTotal = 0.0; /** @var array $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */ $promoRowsByKey = []; + // 本月账单里真正命中「优惠收入 / 冲减收入 / 退款」的行数(重建后自检用: + // 只有账单里确实有这些行,却一行都没写进结果表,才算异常)。 + $moneyRemarkRows = 0; foreach ($bills as $bill) { $storeName = (string) ($bill->store_name ?? ''); $billItem = (string) ($bill->bill_item ?? ''); @@ -324,14 +343,19 @@ class SyncStoreReceiptOrderMatches extends Command ); $isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true) || in_array($remark, self::OFFSET_REMARKS, true); + $isRefundRemark = in_array($remark, self::REFUND_REMARKS, true); $isIncomeRemark = in_array($legacyRemark, self::LEDGER_REMARKS, true); - if ($isIncomeRemark && $isPromoRemark) { + if ($isPromoRemark || $isRefundRemark) { + $moneyRemarkRows++; + } + if ($isIncomeRemark && ($isPromoRemark || $isRefundRemark)) { // 已经在收入金额里的行:不重复计入优惠/冲减列,单独提示金额。 $incomeOverlapRows++; $incomeOverlapTotal += (float) $bill->{$amountColumn}; } $bucket = match (true) { $isIncomeRemark => 'income', + $isRefundRemark => 'refund', in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount', in_array($remark, self::OFFSET_REMARKS, true) => 'offset', default => null, @@ -360,12 +384,15 @@ class SyncStoreReceiptOrderMatches extends Command 'discount_seen' => false, 'offset_amount' => 0.0, 'offset_seen' => false, + 'refund_amount' => 0.0, + 'refund_seen' => false, ]; } // 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。 // 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。 - $amount = (float) $bill->{$amountColumn} - (float) ($bill->expense_amount ?? 0); + $amount = (float) $bill->{$amountColumn} + - ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0); if ($bucket === 'income') { $groups[$key]['payment_count']++; $groups[$key]['income_amount'] += $amount; @@ -376,6 +403,10 @@ class SyncStoreReceiptOrderMatches extends Command $groups[$key]['discount_amount'] += $amount; $discountTotal += $amount; $promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1; + } elseif ($bucket === 'refund') { + $groups[$key]['refund_seen'] = true; + $groups[$key]['refund_amount'] += $amount; + $refundTotal += $amount; } else { $groups[$key]['offset_seen'] = true; $groups[$key]['offset_amount'] += $amount; @@ -417,10 +448,11 @@ class SyncStoreReceiptOrderMatches extends Command $orderColumn, $timeColumn, $amountColumn, + $expenseColumn, $realStoreColumn, ); $this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行'); - $this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2)); + $this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2) . ',退款金额合计:' . number_format($refundTotal, 2)); foreach ($groups as $group) { if (! empty($group['planting_seen'])) { $plantingRows++; @@ -712,6 +744,7 @@ class SyncStoreReceiptOrderMatches extends Command 'income_amount' => $income, 'discount_amount' => ! empty($group['discount_seen']) ? round((float) $group['discount_amount'], 2) : null, '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, 'product_amount' => $productAmount, 'paid_amount' => $paidAmount, @@ -754,22 +787,25 @@ class SyncStoreReceiptOrderMatches extends Command $this->line(' 订单侧商品金额合计:' . number_format($goodsTotal, 2)); $this->line(' 差额合计:' . number_format($differenceTotal, 2)); - // 重建后自检:映射表里有「优惠收入 / 冲减收入」的账单项目,但重建出来的行两列一行都没写, - // 说明这次重建用的代码或参数不对(历史上出现过用旧代码重建、把优惠金额整列写成空), - // 这里显式报错,避免静默丢列后没人发现。 + // 重建后自检:本月账单里确实存在「优惠收入 / 冲减收入 / 退款」的账单行,但重建出来的行 + // 三列一行都没写,说明这次重建用的代码或参数不对(历史上出现过用旧代码重建、把优惠金额 + // 整列写成空),这里显式报错,避免静默丢列后没人发现。 $promoFilled = (int) DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth) ->where('platform', (string) $source['label']) ->where(function ($query): void { - $query->whereNotNull('discount_amount')->orWhereNotNull('offset_amount'); + $query->whereNotNull('discount_amount') + ->orWhereNotNull('offset_amount') + ->orWhereNotNull('refund_amount'); }) ->count(); - if ($promoItems !== [] && $promoFilled === 0) { + if ($promoItems !== [] && $promoFilled === 0 && $moneyRemarkRows > 0) { $this->error(sprintf( - '重建后自检不通过:%s %s 的「优惠金额 / 冲减金额」两列一行都没写,但科目映射表里有 %d 个相关账单项目。' + '重建后自检不通过:%s %s 的「优惠金额 / 冲减金额 / 退款金额」三列一行都没写,但本月账单里有 %d 行命中优惠/冲减/退款(科目映射表里有 %d 个相关账单项目)。' . '请确认使用最新代码(优惠金额逻辑见 c8a035a1)重跑,或补跑 store-receipt:order-match:sync --amounts-only。', $month, (string) $source['label'], + $moneyRemarkRows, count($promoItems), )); @@ -849,13 +885,14 @@ class SyncStoreReceiptOrderMatches extends Command $target() ->where('report_month', $reportMonth) ->where('platform', $platform) - ->update(['discount_amount' => null, 'offset_amount' => null, 'planting_amount' => null]); + ->update(['discount_amount' => null, 'offset_amount' => null, 'refund_amount' => null, 'planting_amount' => null]); $discounts = []; $offsets = []; + $refunds = []; $plantings = []; foreach ($groups as $group) { - if (empty($group['discount_seen']) && empty($group['offset_seen']) && empty($group['planting_seen'])) { + if (empty($group['discount_seen']) && empty($group['offset_seen']) && empty($group['refund_seen']) && empty($group['planting_seen'])) { continue; } @@ -870,6 +907,9 @@ class SyncStoreReceiptOrderMatches extends Command if (! empty($group['offset_seen'])) { $offsets[$rowKey] = round((float) $group['offset_amount'], 2); } + if (! empty($group['refund_seen'])) { + $refunds[$rowKey] = round((float) $group['refund_amount'], 2); + } if (! empty($group['planting_seen'])) { $plantings[$rowKey] = round((float) $group['income_amount'], 2); } @@ -877,6 +917,7 @@ class SyncStoreReceiptOrderMatches extends Command $this->updateAmountColumn($connection, $reportMonth, 'discount_amount', $discounts); $this->updateAmountColumn($connection, $reportMonth, 'offset_amount', $offsets); + $this->updateAmountColumn($connection, $reportMonth, 'refund_amount', $refunds); $this->updateAmountColumn($connection, $reportMonth, 'planting_amount', $plantings); // A 方案:本月本平台的补行(payment_count=0 的行)整体重建,保证幂等。 @@ -947,6 +988,7 @@ class SyncStoreReceiptOrderMatches extends Command string $orderColumn, string $timeColumn, string $amountColumn, + string $expenseColumn, string $realStoreColumn, ): array { $result = []; @@ -961,7 +1003,8 @@ class SyncStoreReceiptOrderMatches extends Command $this->billGoodsName($bill), ); $type = in_array($remark, self::DISCOUNT_REMARKS, true) ? 'discount' - : (in_array($remark, self::OFFSET_REMARKS, true) ? 'offset' : null); + : (in_array($remark, self::OFFSET_REMARKS, true) ? 'offset' + : (in_array($remark, self::REFUND_REMARKS, true) ? 'refund' : null)); if ($type === null) { continue; } @@ -994,14 +1037,20 @@ class SyncStoreReceiptOrderMatches extends Command 'discount_seen' => false, 'offset_amount' => 0.0, 'offset_seen' => false, + 'refund_amount' => 0.0, + 'refund_seen' => false, ]; } // 与主流程一致:优惠/冲减都按「收入侧 − 支出侧」取数。 - $amount = (float) $bill->{$amountColumn} - (float) ($bill->expense_amount ?? 0); + $amount = (float) $bill->{$amountColumn} + - ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0); if ($type === 'discount') { $result[$key]['discount_seen'] = true; $result[$key]['discount_amount'] += $amount; + } elseif ($type === 'refund') { + $result[$key]['refund_seen'] = true; + $result[$key]['refund_amount'] += $amount; } else { $result[$key]['offset_seen'] = true; $result[$key]['offset_amount'] += $amount; @@ -1036,6 +1085,7 @@ class SyncStoreReceiptOrderMatches extends Command 'income_amount' => 0, 'discount_amount' => ! empty($group['discount_seen']) ? round((float) $group['discount_amount'], 2) : null, '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' => null, 'product_amount' => null, diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 23aa9b93..51f28a36 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -747,6 +747,7 @@ class Pages extends Controller SUM(income_amount) as income_total, SUM(discount_amount) as discount_total, SUM(offset_amount) as offset_total, + SUM(refund_amount) as refund_total, SUM(planting_amount) as planting_total, SUM(product_amount) as goods_total, SUM(COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))) as difference_total, @@ -769,6 +770,7 @@ class Pages extends Controller 'income_total' => round((float) ($agg->income_total ?? 0), 2), 'discount_total' => round((float) ($agg->discount_total ?? 0), 2), 'offset_total' => round((float) ($agg->offset_total ?? 0), 2), + 'refund_total' => round((float) ($agg->refund_total ?? 0), 2), 'planting_total' => round((float) ($agg->planting_total ?? 0), 2), 'goods_total' => round((float) ($agg->goods_total ?? 0), 2), 'difference_total' => round((float) ($agg->difference_total ?? 0), 2), @@ -1110,6 +1112,7 @@ class Pages extends Controller 'income_amount' => ['income_amount', '收入金额', 'money'], 'discount_amount' => ['discount_amount', '优惠金额', 'money'], 'offset_amount' => ['offset_amount', '冲减金额', 'money'], + 'refund_amount' => ['refund_amount', '退款金额', 'money'], 'planting_amount' => ['planting_amount', '种菜金额', 'money'], 'product_amount' => ['product_amount', '商品金额', 'money'], 'paid_amount' => ['paid_amount', '订单实付金额', 'money'], diff --git a/config/business_navigation.php b/config/business_navigation.php index 232fb673..43184742 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -791,7 +791,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−商品金额,未匹配到商品金额的行按商品金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:商品金额未匹配(账单无单号 / 单号格式异常 / 订单表无此单 / 订单无商品金额 / 无收款账单,未匹配到商品金额);商品金额未加种菜(商品金额=0 且是种菜单)、商品金额取数为0(商品金额=0 但不是种菜单)、商品金额未减冲减(商品金额=收入+优惠 → 差额=冲减金额)、商品金额未减优惠(商品金额=收入+冲减 → 差额=优惠金额)、商品金额未减优惠和冲减(商品金额=收入 → 差额=优惠+冲减);都不满足则留空。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」只取主单(拆单子单不参与,整单没有主单时退化为取全部行),并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(拆单取主单)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−商品金额,未匹配到商品金额的行按商品金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:商品金额未匹配(账单无单号 / 单号格式异常 / 订单表无此单 / 订单无商品金额 / 无收款账单,未匹配到商品金额);商品金额未加种菜(商品金额=0 且是种菜单)、商品金额取数为0(商品金额=0 但不是种菜单)、商品金额未减冲减(商品金额=收入+优惠 → 差额=冲减金额)、商品金额未减优惠(商品金额=收入+冲减 → 差额=优惠金额)、商品金额未减优惠和冲减(商品金额=收入 → 差额=优惠+冲减);都不满足则留空。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], @@ -801,6 +801,7 @@ $navigation = [ ['name' => 'income_amount', 'label' => '收入金额', 'kind' => 'number'], ['name' => 'discount_amount', 'label' => '优惠金额', 'kind' => 'number'], ['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'], + ['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'], ['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'], ['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'], ['name' => 'paid_amount', 'label' => '订单实付金额', 'kind' => 'number'], diff --git a/database/migrations/2026_10_10_120000_add_refund_amount_to_store_receipt_order_match_rows.php b/database/migrations/2026_10_10_120000_add_refund_amount_to_store_receipt_order_match_rows.php new file mode 100644 index 00000000..93adfa0c --- /dev/null +++ b/database/migrations/2026_10_10_120000_add_refund_amount_to_store_receipt_order_match_rows.php @@ -0,0 +1,43 @@ +connection()); + if (! $schema->hasTable('store_receipt_order_match_rows') + || $schema->hasColumn('store_receipt_order_match_rows', 'refund_amount')) { + return; + } + + $schema->table('store_receipt_order_match_rows', function (Blueprint $table): void { + $table->decimal('refund_amount', 20, 4)->nullable()->after('offset_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', 'refund_amount')) { + return; + } + + $schema->table('store_receipt_order_match_rows', function (Blueprint $table): void { + $table->dropColumn('refund_amount'); + }); + } +}; diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 991ca6ce..307455bd 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1094,6 +1094,7 @@ 账单收入合计{{ number_format((float) $receiptStats['income_total'], 2) }} 优惠金额合计{{ number_format((float) ($receiptStats['discount_total'] ?? 0), 2) }} 冲减金额合计{{ number_format((float) ($receiptStats['offset_total'] ?? 0), 2) }} + 退款金额合计{{ number_format((float) ($receiptStats['refund_total'] ?? 0), 2) }} 种菜金额合计{{ number_format((float) ($receiptStats['planting_total'] ?? 0), 2) }} 订单商品金额合计{{ number_format((float) $receiptStats['goods_total'], 2) }} 差额合计{{ number_format((float) $receiptStats['difference_total'], 2) }}