diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index a458f259..be5691f3 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -210,13 +210,96 @@ class SyncStoreReceiptOrderMatches extends Command } try { $result = $this->syncRows(); + if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) { + $this->refreshCrossMonthBalances( + trim((string) $this->argument('month')), + (string) $this->option('platform'), + ); + } } finally { $lock->release(); } - if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) $this->refreshStoreReportPaidSales(); + if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) { + $this->refreshStoreReportPaidSales(); + } return $result; } + /** + * 同一平台、店铺、交易单号在相邻月份的收入/优惠/冲减/违约赔付合并后, + * 与订单实付(各月重复为同一订单级金额,取最大值)在 2 分钱内对平, + * 才把涉及的月份标记为“跨月对平”。未对平不标记。 + */ + private function refreshCrossMonthBalances(string $month, string $platform): void + { + $source = self::SOURCES[$platform] ?? null; + if ($source === null || in_array($platform, self::EXCLUDED_TARGET_SOURCES, true)) { + return; + } + + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + if (! Schema::connection($connection)->hasColumn(self::TARGET_TABLE, 'cross_month_balanced')) { + return; + } + + $start = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfMonth(); + $label = (string) $source['label']; + $table = DB::connection($connection)->getQueryGrammar()->wrapTable(self::TARGET_TABLE); + $net = 'COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)' + . ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)'; + $affected = 0; + + // 分别重算上个月、本月、下个月。每行只根据自身前后各一个月判断, + // 后续同步新月份时也能同步修正相邻月份,不会因只重置整段范围留下旧标记。 + foreach ([ + $start->copy()->subMonthNoOverflow(), + $start->copy(), + $start->copy()->addMonthNoOverflow(), + ] as $targetMonth) { + $targetDate = $targetMonth->toDateString(); + $rangeStart = $targetMonth->copy()->subMonthNoOverflow()->toDateString(); + $rangeEnd = $targetMonth->copy()->addMonthsNoOverflow(2)->toDateString(); + + $affected += DB::connection($connection)->transaction( + function () use ($connection, $table, $net, $label, $targetDate, $rangeStart, $rangeEnd): int { + DB::connection($connection)->update( + "UPDATE {$table} + SET cross_month_balanced = 0 + WHERE platform = ? AND report_month = ?", + [$label, $targetDate], + ); + + return DB::connection($connection)->update( + "UPDATE {$table} AS target + JOIN ( + SELECT platform, store_name, online_order_no + FROM {$table} + WHERE platform = ? AND report_month >= ? AND report_month < ? + AND COALESCE(store_name, '') <> '' + AND COALESCE(online_order_no, '') <> '' + GROUP BY platform, store_name, online_order_no + HAVING COUNT(DISTINCT report_month) >= 2 + AND SUM(ABS(COALESCE(income_amount, 0)) + + ABS(COALESCE(discount_amount, 0)) + + ABS(COALESCE(offset_amount, 0)) + + ABS(COALESCE(default_compensation_amount, 0))) > 0.005 + AND MAX(COALESCE(paid_amount, 0)) > 0.005 + AND ABS(SUM({$net}) - MAX(COALESCE(paid_amount, 0))) <= 0.02 + ) AS balanced + ON balanced.platform = target.platform + AND balanced.store_name = target.store_name + AND balanced.online_order_no = target.online_order_no + SET target.cross_month_balanced = 1 + WHERE target.platform = ? AND target.report_month = ?", + [$label, $rangeStart, $rangeEnd, $label, $targetDate], + ); + } + ); + } + + $this->line(sprintf(' 跨月合并对平:重算 %s 前后月,标记 %d 行', $start->format('Y-m'), $affected)); + } + private function syncRows(): int { // 账单逐行读取,但全月订单聚合与匹配索引仍可能超过 1G(拼多多 / 淘系支付宝), diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index c25d9964..f3cf6b39 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -339,6 +339,9 @@ class Pages extends Controller $rules = []; $adjustment = self::receiptAdjustmentSql(); $effective = 'COALESCE(difference, ' . self::receiptNetSql() . ')'; + // 同一交易单号在相邻月份的收入/优惠/冲减合并后与订单实付对平时, + // 这是最能解释单月差额的原因,必须排在其它逐月规则之前。 + $rules['差额=跨月收款(合并已对平)'] = 'cross_month_balanced = 1'; $rules[$label . '未匹配'] = "match_status = '未匹配'"; $rules[$label . '取数为0(种菜)'] = "match_status = '已匹配' AND planting_amount IS NOT NULL AND ABS(COALESCE($column, 0)) < 0.005"; $rules[$label . '取数为0'] = "match_status = '已匹配' AND planting_amount IS NULL AND ABS(COALESCE($column, 0)) < 0.005"; @@ -654,6 +657,10 @@ class Pages extends Controller return null; } + if ((int) ($row->cross_month_balanced ?? 0) === 1) { + return '差额=跨月收款(合并已对平)'; + } + if (($row->match_status ?? '') === '未匹配') { // 细分原因放到「未匹配原因」列,这里只说明差额是怎么来的。 return $label . '未匹配'; @@ -1058,8 +1065,22 @@ class Pages extends Controller $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'billing-details'), 403, '当前账号没有该页面权限。'); + return $this->streamBillingDetailsExport($request, [], '账单明细'); + } + + /** 费用明细导出:财务类型固定为「费用」。 */ + public function exportExpenseDetails(Request $request): StreamedResponse + { + $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); + abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'expense-details'), 403, '当前账号没有该页面权限。'); + + return $this->streamBillingDetailsExport($request, ['type' => ['费用']], '费用明细'); + } + + private function streamBillingDetailsExport(Request $request, array $forcedFilters, string $filenamePrefix): StreamedResponse + { $pageData = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class); - $filters = $pageData->exportFilters($request->all()); + $filters = $pageData->exportFilters($request->all(), $forcedFilters); $columns = $pageData->exportColumns(array_values(array_filter( (array) $request->get('columns', []), static fn ($key): bool => is_string($key) && $key !== '', @@ -1117,7 +1138,7 @@ class Pages extends Controller fclose($csv); $putProgress(['status' => 'done', 'rows' => $written, 'total' => max($total, $written)]); - }, '账单明细_' . $filters['start'] . '_' . $filters['end'] . '.csv', [ + }, $filenamePrefix . '_' . $filters['start'] . '_' . $filters['end'] . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); @@ -1136,6 +1157,21 @@ class Pages extends Controller ]); } + /** 费用明细筛选联动:财务类型固定为「费用」。 */ + public function expenseDetailsFilterOptions(): \Illuminate\Http\JsonResponse + { + $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); + abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'expense-details'), 403, '当前账号没有该页面权限。'); + + $data = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class); + + return response()->json([ + 'facets' => $data->facets(request()->all(), ['type' => ['费用']]), + ], 200, [ + 'Cache-Control' => 'private, no-store', + ]); + } + /** 账单明细导出进度(前端每秒轮询一次) */ public function billingDetailsExportProgress(Request $request): \Illuminate\Http\JsonResponse { @@ -4002,6 +4038,7 @@ class Pages extends Controller 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), 'account-mapping' => $this->contentForAccountMapping(), 'ledger' => app(\Modules\EcommerceFinance\View\FinanceLedgerPageData::class)->page($tab), + 'expense-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->expensePage(), 'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(), 'store-violation-alert' => $this->contentForStoreViolationAlerts(), 'preview-statistics' => $this->contentForPreviewStatistics(), diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index 496baf59..7ac43875 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -107,6 +107,15 @@ class SidebarPermissions return $navigation; } + // 费用明细承接原「广告费用 / 营销费用」入口,并复用账单明细的数据权限。 + // 已保存过精确侧边栏权限的账号不会自动获得新页面键,因此按旧权限兼容显示。 + $inheritsExpenseDetails = array_intersect([ + 'financial-operations/performance-details', + 'financial-operations/billing-details', + 'financial-operations/advertising-expenses', + 'financial-operations/marketing-expenses', + ], $allowed) !== []; + $navigation = array_values(array_filter(array_map(function (array $section) use ($allowed) { $sectionKey = $section['slug']; @@ -150,6 +159,50 @@ class SidebarPermissions return null; }, $navigation))); + if ($inheritsExpenseDetails) { + $configuredFinance = collect(BusinessNavigation::all()) + ->firstWhere('slug', 'financial-operations'); + $expensePage = collect($configuredFinance['pages'] ?? []) + ->firstWhere('slug', 'expense-details'); + $financeSectionFound = false; + + foreach ($navigation as &$financeSection) { + if (($financeSection['slug'] ?? null) !== 'financial-operations') { + continue; + } + + $financeSectionFound = true; + if (! $expensePage + || collect($financeSection['pages'] ?? [])->contains('slug', 'expense-details')) { + continue; + } + + $pageSlugs = array_column($financeSection['pages'] ?? [], 'slug'); + $performanceIndex = array_search('performance-details', $pageSlugs, true); + $billingIndex = array_search('billing-details', $pageSlugs, true); + $insertAt = is_int($performanceIndex) + ? $performanceIndex + 1 + : (is_int($billingIndex) ? $billingIndex : 0); + array_splice($financeSection['pages'], $insertAt, 0, [$expensePage]); + } + unset($financeSection); + + // 只被授予旧费用入口的账号仍应保留这个替代页面。 + if (! $financeSectionFound && $expensePage) { + $fallbackSection = $configuredFinance; + $fallbackSection['pages'] = [$expensePage]; + $riskIndex = collect($navigation)->search( + static fn (array $section): bool => ($section['slug'] ?? null) === 'financial-risk-control' + ); + array_splice( + $navigation, + is_int($riskIndex) ? $riskIndex : count($navigation), + 0, + [$fallbackSection], + ); + } + } + // 新增的公司成本安排、税金预测表与主体经营报表共用公司经营报表权限。 // 对已经保存过精确权限的账号兼容旧配置,避免新增页面被旧权限集合隐藏。 foreach ($navigation as &$section) { diff --git a/config/business_navigation.php b/config/business_navigation.php index 32d189e2..9f6f5e11 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 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;具体规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;同一平台、店铺、交易单号在相邻月份合并收入/优惠/冲减/违约赔付后与订单实付对平的,优先标「差额=跨月收款(合并已对平)」,未对平仍标「差额待核实」;其余规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], @@ -844,6 +844,7 @@ $navigation = [ ], ], ], + ['slug' => 'expense-details', 'title' => '费用明细'], ['slug' => 'billing-details', 'title' => '账单明细'], ['slug' => 'product-costs', 'title' => '商品成本'], [ @@ -871,8 +872,6 @@ $navigation = [ ], ['slug' => 'installation-details', 'title' => '安装明细'], ['slug' => 'promotion-expenses', 'title' => '推广费用'], - ['slug' => 'advertising-expenses', 'title' => '广告费用'], - ['slug' => 'marketing-expenses', 'title' => '营销费用'], ['slug' => 'after-sales-expenses', 'title' => '售后费用'], ['slug' => 'small-payment-work-orders', 'title' => '小额打款'], ['slug' => 'management-expenses', 'title' => '管理费用'], diff --git a/database/migrations/2026_10_24_210000_add_cross_month_balanced_to_store_receipt_order_match_rows.php b/database/migrations/2026_10_24_210000_add_cross_month_balanced_to_store_receipt_order_match_rows.php new file mode 100644 index 00000000..c8f6b839 --- /dev/null +++ b/database/migrations/2026_10_24_210000_add_cross_month_balanced_to_store_receipt_order_match_rows.php @@ -0,0 +1,61 @@ +hasTable('store_receipt_order_match_rows')) { + return; + } + + if (! $schema->hasColumn('store_receipt_order_match_rows', 'cross_month_balanced')) { + $schema->table('store_receipt_order_match_rows', function (Blueprint $table): void { + $table->boolean('cross_month_balanced')->default(false)->after('difference'); + }); + } + + $indexName = 'store_receipt_cross_month_lookup_index'; + $hasIndex = collect($schema->getIndexes('store_receipt_order_match_rows')) + ->contains(static fn (array $index): bool => ($index['name'] ?? null) === $indexName); + if (! $hasIndex) { + $schema->table('store_receipt_order_match_rows', function (Blueprint $table) use ($indexName): void { + $table->index( + ['platform', 'report_month', 'store_name', 'online_order_no'], + $indexName, + ); + }); + } + } + + public function down(): void + { + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $schema = Schema::connection($connection); + + if (! $schema->hasTable('store_receipt_order_match_rows')) { + return; + } + + $indexName = 'store_receipt_cross_month_lookup_index'; + $hasIndex = collect($schema->getIndexes('store_receipt_order_match_rows')) + ->contains(static fn (array $index): bool => ($index['name'] ?? null) === $indexName); + if ($hasIndex) { + $schema->table('store_receipt_order_match_rows', function (Blueprint $table) use ($indexName): void { + $table->dropIndex($indexName); + }); + } + + if ($schema->hasColumn('store_receipt_order_match_rows', 'cross_month_balanced')) { + $schema->table('store_receipt_order_match_rows', function (Blueprint $table): void { + $table->dropColumn('cross_month_balanced'); + }); + } + } +}; diff --git a/modules/EcommerceFinance/View/BillingDetailsPageData.php b/modules/EcommerceFinance/View/BillingDetailsPageData.php index 802a2aa2..85aa99e3 100644 --- a/modules/EcommerceFinance/View/BillingDetailsPageData.php +++ b/modules/EcommerceFinance/View/BillingDetailsPageData.php @@ -22,6 +22,8 @@ use Throwable; */ class BillingDetailsPageData { + private const EXPENSE_FORCED_FILTERS = ['type' => ['费用']]; + /** * 各平台账单表;periods 非空表示该表按 active_generation 区分版本,必须用 periods 表锁定当前版本。 */ @@ -76,6 +78,18 @@ class BillingDetailsPageData ] + $this->data(); } + /** 费用明细:复用账单明细,但服务端固定财务类型为「费用」。 */ + public function expensePage(): array + { + return [ + 'view' => 'business.pages.financial-operations.billing-details', + 'options' => $this->options(), + 'columns' => self::COLUMNS, + 'pageTitle' => '费用明细', + 'expenseOnly' => true, + ] + $this->data(null, self::EXPENSE_FORCED_FILTERS); + } + /** * 页面与导出的列定义(顺序即默认显示顺序):key => [标签, 类型]。 */ @@ -97,9 +111,9 @@ class BillingDetailsPageData ]; /** 导出用:按请求解析筛选条件(与页面同一套口径) */ - public function exportFilters(array $input): array + public function exportFilters(array $input, array $forcedFilters = []): array { - return $this->parseFilters($input); + return array_replace($this->parseFilters($input), $forcedFilters); } /** 导出用:当前筛选下的总行数(进度百分比用) */ @@ -258,10 +272,10 @@ class BillingDetailsPageData } /** @return array */ - public function data(?array $input = null): array + public function data(?array $input = null, array $forcedFilters = []): array { $input ??= request()->all(); - $filters = $this->parseFilters($input); + $filters = array_replace($this->parseFilters($input), $forcedFilters); $rows = []; $total = 0; @@ -533,10 +547,10 @@ class BillingDetailsPageData * * @return array,counts:array}> */ - public function facets(?array $input = null): array + public function facets(?array $input = null, array $forcedFilters = []): array { $input ??= request()->all(); - $filters = $this->parseFilters($input); + $filters = array_replace($this->parseFilters($input), $forcedFilters); $signature = md5((string) json_encode($filters, JSON_UNESCAPED_UNICODE)); try { diff --git a/resources/views/business/pages/financial-operations/billing-details.blade.php b/resources/views/business/pages/financial-operations/billing-details.blade.php index 231bd0a3..5ec93c07 100644 --- a/resources/views/business/pages/financial-operations/billing-details.blade.php +++ b/resources/views/business/pages/financial-operations/billing-details.blade.php @@ -1,4 +1,17 @@ -
+@php + $pageTitle = $pageTitle ?? '账单明细'; + $expenseOnly = (bool) ($expenseOnly ?? false); + $filterOptionsRoute = $expenseOnly + ? route('business.financial-operations.expense-details.filter-options') + : route('business.financial-operations.billing-details.filter-options'); + $exportRoute = $expenseOnly + ? route('business.financial-operations.expense-details.export') + : route('business.financial-operations.billing-details.export'); + $exportProgressRoute = $expenseOnly + ? route('business.financial-operations.expense-details.export-progress') + : route('business.financial-operations.billing-details.export-progress'); +@endphp +
@include('business.pages.partials.upstream-data-styles') @include('business.pages.partials.multi-select') @include('business.pages.partials.date-range-picker') @@ -84,9 +97,14 @@ @media (max-width:520px) { .pd-stats { grid-template-columns:1fr; } } -

账单明细

+

{{ $pageTitle }}

数据源=各平台已导入账单(与「店铺收款账户账单」同源);财务备注、财务类型由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。 + @if ($expenseOnly) + 本页只展示科目映射表中财务类型=费用的账单。 + @else + 同一交易单号跨月合并后已与订单实付对平的,差额备注优先显示为差额=跨月收款(合并已对平);未对平仍显示差额待核实。 + @endif 账单项目不再单独筛选,直接在关键词里输入即可(关键词同时匹配记录编码 / 订单号 / 账单项目 / 商户订单号 / 备注)。
@@ -143,6 +161,7 @@
+ @if (! $expenseOnly)
财务类型 @@ -154,6 +173,7 @@
+ @endif
财务备注 @@ -199,7 +219,7 @@
{{ $error }}
@else
-
账单明细{{ number_format($summary['rows']) }} 条
+
{{ $pageTitle }}{{ number_format($summary['rows']) }} 条