From 412b451982ce133ab3c0a9505bbe3e8740e72e0e Mon Sep 17 00:00:00 2001 From: lunanting <404877757@qq.com> Date: Sat, 24 Oct 2026 16:20:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=B8=9A=E7=BB=A9?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E9=87=91=E9=A2=9D=E6=8C=87=E6=A0=87=E4=B8=8E?= =?UTF-8?q?=E8=B4=A6=E5=8D=95=E6=98=8E=E7=BB=86=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/SyncStoreReceiptOrderMatches.php | 166 ++- app/Http/Controllers/Business/Pages.php | 241 ++++ ...ore_receipt_order_match_metric_details.php | 51 + .../pages/partials/directory-table.blade.php | 1040 +++++++++++++---- routes/admin.php | 4 + .../PerformanceDetailsMetricDetailsTest.php | 152 +++ tests/Feature/Settings/NavigationTest.php | 4 +- .../StoreReceiptMemoryTest.php | 75 ++ 8 files changed, 1498 insertions(+), 235 deletions(-) create mode 100644 database/migrations/2026_10_24_150000_create_store_receipt_order_match_metric_details.php create mode 100644 tests/Feature/Business/PerformanceDetailsMetricDetailsTest.php diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index 36eebac9..a458f259 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -13,6 +13,7 @@ use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap; use Modules\EcommerceFinance\Services\AccountMappingStore; use Modules\EcommerceFinance\Services\LedgerRemarkFilter; use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer; +use RuntimeException; use Throwable; /** @@ -143,6 +144,8 @@ class SyncStoreReceiptOrderMatches extends Command private const TARGET_TABLE = 'store_receipt_order_match_rows'; + private const METRIC_DETAIL_TABLE = 'store_receipt_order_match_metric_details'; + private const STATUS_MATCHED = '已匹配'; private const STATUS_UNMATCHED = '未匹配'; @@ -253,6 +256,10 @@ class SyncStoreReceiptOrderMatches extends Command ->where('report_month', $reportMonth) ->where('platform', (string) $source['label']) ->delete(); + DB::connection($connection)->table(self::METRIC_DETAIL_TABLE) + ->where('report_month', $reportMonth) + ->where('platform', (string) $source['label']) + ->delete(); } $this->warn(sprintf( '%s 已从业绩明细排除,%s 删除镜像行 %d 行(收、支均不取)。', @@ -457,7 +464,13 @@ class SyncStoreReceiptOrderMatches extends Command )); unset($plantingCores); if ((bool) $this->option('amounts-only')) { - return $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth); + $result = $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth); + if ($result === self::SUCCESS && ! (bool) $this->option('dry-run')) { + $this->replaceMetricDetails($reportMonth, (string) $source['label'], $groups, $promoOnlyGroups, now()); + $this->verifyMetricDetailTotals($reportMonth, (string) $source['label']); + } + + return $result; } // 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字, @@ -663,6 +676,10 @@ class SyncStoreReceiptOrderMatches extends Command ->where('report_month', $reportMonth) ->where('platform', (string) $source['label']) ->delete(); + DB::connection($connection)->table(self::METRIC_DETAIL_TABLE) + ->where('report_month', $reportMonth) + ->where('platform', (string) $source['label']) + ->delete(); } $target = DB::connection($connection)->table(self::TARGET_TABLE); @@ -869,6 +886,17 @@ class SyncStoreReceiptOrderMatches extends Command $imported += count($buffer); } + if (! $dryRun) { + $this->replaceMetricDetails( + $reportMonth, + (string) $source['label'], + $groups, + $promoOnlyGroups, + $now, + ); + $this->verifyMetricDetailTotals($reportMonth, (string) $source['label']); + } + $this->info('写入完成,共 ' . $imported . ' 行(账单收款 ' . $paymentTotal . ' 笔)。'); foreach ($stats as $status => $count) { $this->line(' ' . $status . ':' . $count); @@ -1290,6 +1318,7 @@ class SyncStoreReceiptOrderMatches extends Command 'default_compensation_seen' => false, 'refund_amount' => 0.0, 'refund_seen' => false, + 'bill_items' => [], ]; } @@ -1318,11 +1347,23 @@ class SyncStoreReceiptOrderMatches extends Command 'default_compensation_seen' => false, 'refund_amount' => 0.0, 'refund_seen' => false, + 'bill_items' => [], ]; } $promoOnlyGroups[$promoKey][$bucket . '_seen'] = true; $promoOnlyGroups[$promoKey][$bucket . '_amount'] += $amount; } + $billItemKey = \Illuminate\Support\Str::limit($billItem !== '' ? $billItem : '未命名账单项目', 191, ''); + $groups[$key]['bill_items'][$bucket][$billItemKey]['amount'] = + ($groups[$key]['bill_items'][$bucket][$billItemKey]['amount'] ?? 0.0) + $amount; + $groups[$key]['bill_items'][$bucket][$billItemKey]['line_count'] = + ($groups[$key]['bill_items'][$bucket][$billItemKey]['line_count'] ?? 0) + 1; + if ($bucket !== 'income') { + $promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['amount'] = + ($promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['amount'] ?? 0.0) + $amount; + $promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['line_count'] = + ($promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['line_count'] ?? 0) + 1; + } if ($bucket === 'income') { $groups[$key]['payment_count']++; $groups[$key]['income_amount'] += $amount; @@ -1440,6 +1481,129 @@ class SyncStoreReceiptOrderMatches extends Command ]; } + /** + * 重建一个平台×账期的账单项目金额明细。 + * + * 汇总值来自同一遍账单读取,并通过 row_key 绑定主表行;页面按主表当前筛选 + * 条件关联本表,所以详情合计和指标卡使用同一批订单。 + * + * @param array> $groups + * @param array> $promoOnlyGroups + */ + private function replaceMetricDetails( + string $reportMonth, + string $platform, + array $groups, + array $promoOnlyGroups, + mixed $now, + ): void { + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $target = DB::connection($connection)->table(self::METRIC_DETAIL_TABLE); + $target->where('report_month', $reportMonth) + ->where('platform', $platform) + ->delete(); + + $buffer = []; + $append = static function (array $group, string $rowKey) use (&$buffer, $target, $reportMonth, $platform, $now): void { + foreach ((array) ($group['bill_items'] ?? []) as $metric => $billItems) { + foreach ($billItems as $billItem => $values) { + $amount = round((float) ($values['amount'] ?? 0), 4); + if (abs($amount) < 0.00005) { + continue; + } + $billItem = trim((string) $billItem) !== '' ? trim((string) $billItem) : '未命名账单项目'; + $buffer[] = [ + 'report_month' => $reportMonth, + 'detail_key' => md5($rowKey . '|' . $metric . '|' . $billItem), + 'row_key' => $rowKey, + 'platform' => $platform, + 'metric' => (string) $metric, + 'bill_item' => $billItem, + 'amount' => $amount, + 'line_count' => (int) ($values['line_count'] ?? 1), + 'imported_at' => $now, + ]; + if (count($buffer) >= 1000) { + $target->insertOrIgnore($buffer); + $buffer = []; + } + } + } + }; + + foreach ($groups as $group) { + $append($group, self::groupRowKey( + $reportMonth, + $platform, + (string) ($group['shop_key'] ?? ''), + (string) ($group['order_identity'] ?? ''), + )); + } + foreach ($promoOnlyGroups as $group) { + $identity = (string) ($group['order_identity'] ?? ''); + $rowKey = $identity !== '' + ? self::groupRowKey($reportMonth, $platform, (string) ($group['shop_key'] ?? ''), $identity) + : md5($reportMonth . '|' . $platform . '|' . ($group['shop_key'] ?? '') . '|promo|' . ($group['bill_item'] ?? '')); + $append($group, $rowKey); + } + + if ($buffer !== []) { + $target->insertOrIgnore($buffer); + } + } + + /** + * 账单项目明细与主表金额列必须逐项相等。任何一分钱差异都视为同步失败, + * 避免页面出现“指标总额”和“明细合计”对不上的情况。 + */ + private function verifyMetricDetailTotals(string $reportMonth, string $platform): void + { + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $main = DB::connection($connection)->table(self::TARGET_TABLE) + ->where('report_month', $reportMonth) + ->where('platform', $platform) + ->selectRaw( + 'SUM(COALESCE(income_amount, 0)) as income, ' + . 'SUM(COALESCE(discount_amount, 0)) as discount, ' + . 'SUM(COALESCE(offset_amount, 0)) as offset_amount, ' + . 'SUM(COALESCE(default_compensation_amount, 0)) as default_compensation, ' + . 'SUM(COALESCE(refund_amount, 0)) as refund' + ) + ->first(); + $details = DB::connection($connection)->table(self::METRIC_DETAIL_TABLE) + ->where('report_month', $reportMonth) + ->where('platform', $platform) + ->selectRaw( + "SUM(CASE WHEN metric = 'income' THEN amount ELSE 0 END) as income, " + . "SUM(CASE WHEN metric = 'discount' THEN amount ELSE 0 END) as discount, " + . "SUM(CASE WHEN metric = 'offset' THEN amount ELSE 0 END) as offset_amount, " + . "SUM(CASE WHEN metric = 'default_compensation' THEN amount ELSE 0 END) as default_compensation, " + . "SUM(CASE WHEN metric = 'refund' THEN amount ELSE 0 END) as refund" + ) + ->first(); + $labels = [ + 'income' => '收入', + 'discount' => '优惠', + 'offset_amount' => '冲减', + 'default_compensation' => '违约赔付', + 'refund' => '退款', + ]; + foreach ($labels as $column => $label) { + $mainTotal = round((float) ($main->{$column} ?? 0), 2); + $detailTotal = round((float) ($details->{$column} ?? 0), 2); + if (abs($mainTotal - $detailTotal) > 0.005) { + throw new RuntimeException(sprintf( + '账单项目明细校验失败:%s %s %s,主表 %s,明细 %s。', + $reportMonth, + $platform, + $label, + number_format($mainTotal, 2, '.', ''), + number_format($detailTotal, 2, '.', ''), + )); + } + } + } + /** @param array $group */ private static function plantingLabel(array $group): ?string { diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index cf62b8b2..c25d9964 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -111,6 +111,18 @@ class Pages extends Controller '得物', ]; + /** 业绩明细可点击指标 → 主表金额列、账单项目明细桶、页面名称。 */ + private const RECEIPT_METRIC_MAP = [ + 'income_amount' => ['label' => '账单收入', 'metric' => 'income'], + 'paid_amount' => ['label' => '订单实付', 'metric' => null], + 'discount_amount' => ['label' => '优惠收入', 'metric' => 'discount'], + 'offset_amount' => ['label' => '冲减收入', 'metric' => 'offset'], + 'default_compensation_amount' => ['label' => '违约赔付', 'metric' => 'default_compensation'], + 'refund_amount' => ['label' => '退款金额', 'metric' => 'refund'], + 'planting_amount' => ['label' => '种菜金额', 'metric' => null], + 'difference' => ['label' => '差额合计', 'metric' => null], + ]; + /** 业绩明细不纳入的收款账户:京东网银钱包的收、支均不取。 */ private const RECEIPT_MATCH_EXCLUDED_PLATFORMS = [ '京东网银钱包', @@ -1537,6 +1549,235 @@ class Pages extends Controller ]), $stats]; } + /** + * 业绩明细指标卡点击后的明细汇总。 + * + * 收入、优惠、冲减、违约赔付、退款从账单项目明细表按「平台 + 店铺 + 账单项目」 + * 汇总;订单实付、种菜金额、差额没有账单项目维度,直接按主表行汇总。 + */ + public function receiptOrderMatchMetricDetails(Request $request) + { + $metric = trim((string) $request->query('metric', '')); + $definition = self::RECEIPT_METRIC_MAP[$metric] ?? null; + if ($definition === null) { + return response()->json(['message' => '不支持的业绩明细指标。'], 422); + } + + [, $month] = $this->receiptOrderMatchMonths(); + if ($month === null) { + return response()->json([ + 'metric' => $metric, + 'label' => $definition['label'], + 'month' => null, + 'total' => 0, + 'detail_total' => 0, + 'balance' => 0, + 'balanced' => true, + 'rows' => [], + ]); + } + + $query = $this->receiptOrderMatchFilteredQuery($month); + $total = $this->receiptOrderMatchMetricTotal($query, $metric); + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $detailMetric = $definition['metric']; + + if ($detailMetric !== null) { + $filteredRows = (clone $query)->select([ + 'report_month', + 'row_key', + 'platform', + 'store_name', + ]); + $rows = DB::connection($connection) + ->query() + ->fromSub($filteredRows, 'filtered_receipt_rows') + ->join('store_receipt_order_match_metric_details as metric_details', function ($join): void { + $join->on('metric_details.report_month', '=', 'filtered_receipt_rows.report_month') + ->on('metric_details.row_key', '=', 'filtered_receipt_rows.row_key'); + }) + ->where('metric_details.metric', $detailMetric) + ->select([ + 'filtered_receipt_rows.platform', + 'filtered_receipt_rows.store_name', + 'metric_details.bill_item', + ]) + ->selectRaw('SUM(metric_details.amount) as amount') + ->selectRaw('SUM(metric_details.line_count) as line_count') + ->groupBy([ + 'filtered_receipt_rows.platform', + 'filtered_receipt_rows.store_name', + 'metric_details.bill_item', + ]) + ->havingRaw('ABS(SUM(metric_details.amount)) > 0.004') + ->orderBy('filtered_receipt_rows.platform') + ->orderBy('filtered_receipt_rows.store_name') + ->orderBy('metric_details.bill_item') + ->get(); + } elseif ($metric === 'difference') { + // 与汇总使用同一套“默认差额 + 口径兜底修正”,避免对整月每一行都执行 + // 复杂的差额原因子查询;复杂规则只落在实收/应付口径兜底行上。 + $baseRows = (clone $query) + ->select(['platform', 'store_name']) + ->selectRaw('SUM(' . self::receiptDefaultDifferenceSql() . ') as amount') + ->selectRaw('COUNT(*) as line_count') + ->groupBy(['platform', 'store_name']) + ->get(); + $adjustments = (clone $query) + ->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS) + ->select(['platform', 'store_name']) + ->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment') + ->groupBy(['platform', 'store_name']) + ->get(); + + $grouped = []; + foreach ($baseRows as $row) { + $key = (string) $row->platform . '|' . (string) $row->store_name; + $grouped[$key] = [ + 'platform' => (string) $row->platform, + 'store_name' => (string) $row->store_name, + 'amount' => (float) $row->amount, + 'line_count' => (int) $row->line_count, + ]; + } + foreach ($adjustments as $row) { + $key = (string) $row->platform . '|' . (string) $row->store_name; + if (! isset($grouped[$key])) { + $grouped[$key] = [ + 'platform' => (string) $row->platform, + 'store_name' => (string) $row->store_name, + 'amount' => 0.0, + 'line_count' => 0, + ]; + } + $grouped[$key]['amount'] += (float) $row->adjustment; + } + + ksort($grouped, SORT_NATURAL | SORT_FLAG_CASE); + $rows = collect($grouped) + ->filter(static fn (array $row): bool => abs((float) $row['amount']) > 0.004) + ->map(static function (array $row) use ($definition): object { + return (object) [ + 'platform' => $row['platform'], + 'store_name' => $row['store_name'], + 'bill_item' => $definition['label'], + 'amount' => $row['amount'], + 'line_count' => $row['line_count'], + ]; + }) + ->values(); + } else { + $amountSql = 'COALESCE(' . $metric . ', 0)'; + $rows = (clone $query) + ->select(['platform', 'store_name']) + ->selectRaw('SUM(' . $amountSql . ') as amount') + ->selectRaw('COUNT(*) as line_count') + ->groupBy(['platform', 'store_name']) + ->havingRaw('ABS(SUM(' . $amountSql . ')) > 0.004') + ->orderBy('platform') + ->orderBy('store_name') + ->get() + ->map(static function (object $row) use ($definition): object { + $row->bill_item = $definition['label']; + + return $row; + }); + } + + $detailTotal = round((float) $rows->sum(static fn (object $row): float => (float) ($row->amount ?? 0)), 2); + $balance = round($detailTotal - $total, 2); + $rows = $rows->map(static function (object $row): array { + return [ + 'platform' => (string) ($row->platform ?? ''), + 'store_name' => (string) ($row->store_name ?? ''), + 'bill_item' => (string) ($row->bill_item ?? ''), + 'amount' => round((float) ($row->amount ?? 0), 2), + 'line_count' => (int) ($row->line_count ?? 0), + ]; + })->values(); + + return response()->json([ + 'metric' => $metric, + 'label' => $definition['label'], + 'month' => substr($month, 0, 7), + 'total' => round($total, 2), + 'detail_total' => $detailTotal, + 'balance' => $balance, + 'balanced' => abs($balance) <= 0.005, + 'rows' => $rows, + ]); + } + + /** + * 业绩明细当前筛选条件。指标详情与页面统计共用同一组请求参数。 + * + * @return \Illuminate\Database\Query\Builder + */ + private function receiptOrderMatchFilteredQuery(string $month) + { + $platformFilter = $this->receiptOrderMatchFilterValues(request('platform')); + $storeFilter = $this->receiptOrderMatchFilterValues(request('store_name')); + [$statusFilter, $remarkFilter] = $this->receiptOrderMatchStatusFilters( + request('match_status'), + request('remark'), + ); + $unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason')); + $differenceState = $this->receiptOrderMatchFilterValues(request('difference_state')); + $orderNo = trim((string) request('online_order_no', '')); + + $query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')) + ->table('store_receipt_order_match_rows') + ->where('report_month', $month) + ->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS); + + if ($platformFilter !== []) { + $query->whereIn('platform', $platformFilter); + } + if ($storeFilter !== []) { + $query->whereIn('store_name', $storeFilter); + } + if ($orderNo !== '') { + $query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%'); + } + if ($statusFilter !== []) { + $query->whereIn('match_status', $statusFilter); + } + if ($remarkFilter !== []) { + $this->applyReceiptRemarkFilter($query, $remarkFilter); + } + if ($unmatchedReasonFilter !== []) { + $this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonFilter); + } + if ($differenceState !== []) { + $this->applyReceiptDifferenceStateFilter($query, $differenceState); + } + + return $query; + } + + private function receiptOrderMatchMetricTotal($query, string $metric): float + { + if ($metric !== 'difference') { + return round((float) (clone $query)->sum($metric), 2); + } + + $fallbackSql = self::receiptBasisFallbackSqlList(); + $row = (clone $query)->selectRaw( + 'SUM(' . self::receiptDefaultDifferenceSql() . ') as difference_base, ' + . "SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows" + )->first(); + $total = (float) ($row->difference_base ?? 0); + if ((int) ($row->basis_fallback_rows ?? 0) > 0) { + $adjustment = (clone $query) + ->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS) + ->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment') + ->first(); + $total += (float) ($adjustment->adjustment ?? 0); + } + + return round($total, 2); + } + /** * 「差额异常备注」筛选:按差额原因的条件判定,多选内部是「或」。 * diff --git a/database/migrations/2026_10_24_150000_create_store_receipt_order_match_metric_details.php b/database/migrations/2026_10_24_150000_create_store_receipt_order_match_metric_details.php new file mode 100644 index 00000000..4949ed14 --- /dev/null +++ b/database/migrations/2026_10_24_150000_create_store_receipt_order_match_metric_details.php @@ -0,0 +1,51 @@ +connection()); + if ($schema->hasTable('store_receipt_order_match_metric_details')) { + return; + } + + $schema->create('store_receipt_order_match_metric_details', function (Blueprint $table): void { + $table->date('report_month'); + $table->char('detail_key', 64); + $table->char('row_key', 64); + $table->string('platform', 64); + $table->string('metric', 32); + $table->string('bill_item', 191)->default(''); + $table->decimal('amount', 20, 4)->default(0); + $table->unsignedInteger('line_count')->default(1); + $table->dateTime('imported_at'); + + $table->primary(['report_month', 'detail_key'], 'receipt_metric_details_primary'); + $table->index(['report_month', 'row_key', 'metric'], 'receipt_metric_details_row_metric_index'); + $table->index(['report_month', 'platform', 'metric'], 'receipt_metric_details_metric_index'); + $table->index(['report_month', 'bill_item'], 'receipt_metric_details_item_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection()) + ->dropIfExists('store_receipt_order_match_metric_details'); + } +}; diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 03b268fb..cff8850e 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -739,185 +739,398 @@ display: none; } - /* 业绩明细:把长口径说明拆成可扫描的规则分组,完整原文默认折叠 */ - .receipt-method-guide { + /* 业绩明细:指标卡作为首屏唯一汇总入口,详细账单项目在弹层中按需查看。 */ + .receipt-metric-board { margin: 0 0 1rem; - padding: .9rem 1rem; - border: 1px solid var(--vip-border, #d8d2c7); - border-radius: .75rem; - background: #fff; } - .receipt-method-guide__grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); - gap: .7rem 1.5rem; + .receipt-metric-board__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: .75rem; + margin-bottom: .65rem; } - .receipt-method-guide__section { - min-width: 0; - } - - .receipt-method-guide__section h3 { - margin: 0 0 .3rem; + .receipt-metric-board__head strong { + display: block; color: var(--vip-text, #171717); + font-size: .8125rem; + font-weight: 600; + } + + .receipt-metric-board__head span { + display: block; + margin-top: .18rem; + color: var(--vip-text-muted, #595959); + font-size: .75rem; + } + + .receipt-metric-rules { + flex: 0 0 auto; + min-height: 2rem; + padding: 0 .65rem; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: .5rem; + background: #fff; + color: var(--vip-content-accent-dark, #856632); + font: inherit; font-size: .75rem; font-weight: 600; + cursor: pointer; + } + + .receipt-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); + gap: .55rem; + } + + .receipt-metric-card { + display: grid; + min-height: 5.4rem; + padding: .7rem .8rem; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: .65rem; + background: #fff; + color: var(--vip-text, #171717); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 150ms ease, background-color 150ms ease; + } + + .receipt-metric-card:hover, + .receipt-metric-card:focus-visible { + border-color: var(--vip-content-accent, #b9954f); + background: #fffdf8; + } + + .receipt-metric-card:focus-visible { + outline: 2px solid var(--vip-content-accent-dark, #856632); + outline-offset: 2px; + } + + .receipt-metric-card__label { + color: var(--vip-text-muted, #595959); + font-size: .75rem; line-height: 1.4; } - .receipt-method-guide__section ul { - display: grid; - gap: .12rem; - margin: 0; - padding-left: 1rem; - color: var(--vip-text-muted, #595959); - font-size: .75rem; - line-height: 1.55; - } - - .receipt-method-guide__formula { - color: var(--vip-text, #171717); - font-weight: 600; - } - - .receipt-method-guide__chips { - display: flex; - flex-wrap: wrap; - gap: .3rem; - } - - .receipt-method-guide__chip { - display: inline-flex; - align-items: center; - padding: .08rem .4rem; - border: 1px solid #e8dfcd; - border-radius: .3rem; - background: #fffdf8; - color: var(--vip-text-muted, #595959); - font-size: .6875rem; - line-height: 1.5; - } - - .receipt-method-guide__details { - margin-top: .7rem; - padding-top: .65rem; - border-top: 1px solid #eee7da; - } - - .receipt-method-guide__details summary { - color: var(--vip-content-accent-dark, #856632); - cursor: pointer; - font-size: .75rem; - font-weight: 600; - } - - .receipt-method-guide__details p { - margin: .55rem 0 0; - color: var(--vip-text-muted, #595959); - font-size: .75rem; - line-height: 1.7; - } - - /* 业绩明细:店铺收款匹配统计条 */ - .receipt-match-summary { - display: flex; - flex-wrap: wrap; - gap: .75rem; - margin: 0 0 1rem; - padding: .85rem 1rem; - border: 1px solid var(--vip-border, #d8d2c7); - border-radius: .75rem; - background: #fff; - } - - /* 业绩明细统计:分组卡片(账单与匹配 / 金额构成 / 差异),避免十几个数字平铺看不出来 */ - .receipt-match-summary__group { - flex: 1 1 18rem; - min-width: 0; - padding: .5rem .75rem; - border: 1px solid #eee7da; - border-radius: .5rem; - background: #fffdf8; - } - - .receipt-match-summary__title { - margin-bottom: .35rem; - color: #8a8377; - font-size: .6875rem; - letter-spacing: .02em; - } - - .receipt-match-summary__items { - display: flex; - flex-wrap: wrap; - gap: .35rem 1.25rem; - } - - .receipt-match-summary__items--amounts { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: .4rem 1.75rem; - } - - @media (max-width: 900px) { - .receipt-match-summary__items--amounts { - grid-template-columns: 1fr; - } - } - - .receipt-match-summary__item--lead strong { - font-size: 1.25rem; - color: #b80000; - } - - .receipt-match-summary__item { - display: inline-flex; - align-items: baseline; - gap: .5rem; - font-size: .8125rem; - color: var(--vip-text-muted, #6b7280); - } - - .receipt-match-summary__item em { - font-style: normal; - } - - .receipt-match-summary__item strong { - font-size: .9375rem; + .receipt-metric-card__value { + align-self: center; + margin-top: .35rem; + font-size: 1.125rem; + font-weight: 700; font-variant-numeric: tabular-nums; - color: var(--vip-text, #1f2937); - } - - .receipt-match-summary__item--amount { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: baseline; - gap: .75rem; - } - - .receipt-match-summary__item--amount strong { - text-align: right; + line-height: 1.25; white-space: nowrap; } - .receipt-match-summary__value-unit { - margin-left: .15em; - color: #8a8377; + .receipt-metric-card__value small { + margin-left: .12rem; + color: var(--vip-text-muted, #595959); font-size: .75rem; font-weight: 500; } - .receipt-match-summary__item.is-ok strong { - color: var(--vip-success, #137a3c); + .receipt-metric-card__action { + align-self: end; + margin-top: .35rem; + color: var(--vip-content-accent-dark, #856632); + font-size: .75rem; } - .receipt-match-summary__item.is-warn strong { - color: var(--vip-warning, #b45309); + .receipt-metric-dialog, + .receipt-rules-dialog { + width: min(72rem, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + margin: auto; + padding: 0; + overflow: hidden; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: .85rem; + background: #fff; + color: var(--vip-text, #171717); + box-shadow: 0 .75rem 2rem rgba(23, 23, 23, .16); } - .receipt-match-summary__item.is-danger strong { - color: var(--vip-danger, #b80000); + .receipt-rules-dialog { + width: min(52rem, calc(100vw - 2rem)); + } + + .receipt-metric-dialog::backdrop, + .receipt-rules-dialog::backdrop { + background: rgba(17, 17, 17, .38); + } + + .receipt-metric-dialog__head, + .receipt-rules-dialog__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + } + + .receipt-metric-dialog__head h2, + .receipt-rules-dialog__head h2 { + margin: 0; + font-size: 1rem; + font-weight: 600; + } + + .receipt-metric-dialog__head p, + .receipt-rules-dialog__head p { + margin: .28rem 0 0; + color: var(--vip-text-muted, #595959); + font-size: .75rem; + } + + .receipt-dialog-close { + display: grid; + flex: 0 0 auto; + width: 2rem; + height: 2rem; + place-items: center; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: .45rem; + background: #fff; + color: var(--vip-text-muted, #595959); + font: inherit; + font-size: 1rem; + cursor: pointer; + } + + .receipt-metric-dialog__head-side { + display: flex; + align-items: flex-start; + gap: .9rem; + } + + .receipt-metric-dialog__total { + display: grid; + justify-items: end; + gap: .12rem; + min-width: 8rem; + } + + .receipt-metric-dialog__total span { + color: var(--vip-text-muted, #595959); + font-size: .75rem; + } + + .receipt-metric-dialog__total strong { + color: var(--vip-text, #171717); + font-size: 1.125rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .receipt-metric-dialog__total small { + margin-left: .15rem; + color: var(--vip-text-muted, #595959); + font-size: .75rem; + font-weight: 500; + } + + .receipt-metric-filters { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .65rem; + padding: .75rem 1.1rem; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + background: #fff; + } + + .receipt-metric-filter { + display: grid; + gap: .25rem; + min-width: 0; + color: var(--vip-text-muted, #595959); + font-size: .75rem; + } + + .receipt-metric-filter select { + width: 100%; + height: 2rem; + padding: 0 1.8rem 0 .55rem; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: .45rem; + background: #fff; + color: var(--vip-text, #171717); + font: inherit; + font-size: .75rem; + } + + .receipt-metric-filter select:focus { + border-color: var(--vip-content-accent-dark, #856632); + outline: 2px solid rgba(133, 102, 50, .18); + outline-offset: 1px; + } + + .receipt-metric-tabs { + display: flex; + gap: 1.2rem; + padding: 0 1.1rem; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + background: #fff; + } + + .receipt-metric-tab { + position: relative; + min-height: 2.6rem; + padding: 0; + border: 0; + background: transparent; + color: var(--vip-text-muted, #595959); + font: inherit; + font-size: .75rem; + font-weight: 600; + cursor: pointer; + } + + .receipt-metric-tab[aria-selected="true"] { + color: var(--vip-content-accent-dark, #856632); + } + + .receipt-metric-tab[aria-selected="true"]::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + background: var(--vip-content-accent, #b9954f); + content: ''; + } + + .receipt-metric-tab:focus-visible { + outline: 2px solid var(--vip-content-accent-dark, #856632); + outline-offset: 2px; + } + + .receipt-metric-panel[hidden] { + display: none; + } + + .receipt-metric-dialog__scroll { + max-height: min(58vh, 36rem); + overflow: auto; + } + + .receipt-metric-table { + width: 100%; + border-collapse: collapse; + font-size: .78125rem; + } + + .receipt-metric-table th { + position: sticky; + top: 0; + z-index: 1; + padding: .65rem .85rem; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + background: #faf6ec; + color: var(--vip-text-muted, #595959); + font-weight: 600; + text-align: left; + white-space: nowrap; + } + + .receipt-metric-table td { + padding: .6rem .85rem; + border-bottom: 1px solid #eee7da; + color: var(--vip-text, #171717); + vertical-align: middle; + } + + .receipt-metric-table .is-number { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .receipt-metric-dialog__empty { + padding: 2.5rem 1rem; + color: var(--vip-text-muted, #595959); + font-size: .8125rem; + text-align: center; + } + + .receipt-metric-dialog__foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: .75rem 1.1rem; + border-top: 1px solid var(--vip-border, #d8d2c7); + background: #faf8f3; + color: var(--vip-text-muted, #595959); + font-size: .75rem; + } + + .receipt-metric-dialog__foot strong { + color: var(--vip-text, #171717); + font-variant-numeric: tabular-nums; + } + + .receipt-rules-dialog__body { + max-height: calc(100vh - 9rem); + padding: 1rem 1.1rem; + overflow: auto; + color: var(--vip-text-muted, #595959); + font-size: .8125rem; + line-height: 1.7; + } + + .receipt-rules-dialog__body h3 { + margin: 1rem 0 .35rem; + color: var(--vip-text, #171717); + font-size: .8125rem; + font-weight: 600; + } + + .receipt-rules-dialog__body h3:first-child { + margin-top: 0; + } + + .receipt-rules-dialog__body ul { + margin: 0; + padding-left: 1.1rem; + } + + @media (max-width: 720px) { + .receipt-metric-board__head { + flex-direction: column; + } + + .receipt-metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .receipt-metric-filters { + grid-template-columns: 1fr; + } + + .receipt-metric-tabs { + gap: .9rem; + } + + .receipt-metric-dialog__foot { + align-items: flex-start; + flex-direction: column; + } + + .receipt-metric-dialog__head-side { + align-items: flex-end; + } + + .receipt-metric-dialog__total { + min-width: 0; + } + } .inventory-warehouse-mapping { @@ -1194,7 +1407,7 @@
- {{ $page['title'] ?? $section['title'] }} · 字段结构 + {{ $page['title'] ?? $section['title'] }}{{ ($page['slug'] ?? '') === 'performance-details' ? '' : ' · 字段结构' }} @if (!empty($directory['description']) && ($page['slug'] ?? '') !== 'performance-details')

{{ $directory['description'] }}

@endif @@ -1237,86 +1450,172 @@ @endif @endif - @if (($page['slug'] ?? '') === 'performance-details' && !empty($directory['description'])) -
-
-
-

匹配口径

-
    -
  • 一行 = 一个店铺 + 一个线上订单号
  • -
  • 商品金额:主单 → 原单行 → 全部行
  • -
  • 合单/拆单只挂一次,排除 X0000x 虚拟条码
  • -
  • 订单实付取订单级实付,再扣虚拟条码行实付
  • -
-
-
-

金额字段

-
    -
  • 收入:总账备注 = 订单收入
  • -
  • 优惠/冲减:分别取优惠收入、冲减收入,不计入收入
  • -
  • 退款:单独列示,不参与收入和差额
  • -
  • 种菜:种菜订单的收入子集,不参与差额
  • -
  • 标签:种菜明细表命中为“种菜报销”,其余聚水潭种菜标签命中为“种菜未报销”
  • -
-
-
-

差额判定

-
    -
  • 差额 = 收入 + 优惠 + 冲减 − 订单实付
  • -
  • 未匹配时按订单实付 = 0 计入差额
  • -
  • 原因按顺序命中;无明确原因时标「差额待核实」
  • -
  • 卖家实收 / 应付已付能对平的行,差额按 0 计
  • -
-
-
-

匹配原因

-
- 账单无单号 - 单号格式异常 - 订单表无此单 - 订单无实付金额 - 无收款账单 - 1688 采销收款 - 店铺名不一致 - 同单多笔收款 -
-
-
-
- 查看完整字段与差额判定规则 -

{{ $directory['description'] }}

-
-
- @endif - @if (($page['slug'] ?? '') === 'performance-details' && !empty($receiptStats)) - {{-- 匹配统计:账单笔数、成功/未匹配笔数、双方收入合计核对结果 --}} -
-
-
账单与匹配
-
- 账单收款笔数{{ number_format($receiptStats['payments']) }} - 账单订单数{{ number_format($receiptStats['rows']) }} - 已匹配{{ number_format($receiptStats['orders_matched']) }} - 未匹配{{ number_format($receiptStats['orders_unmatched']) }} - 一单多笔{{ number_format($receiptStats['orders_multi_pay']) }} - 收入为 0 的账单行{{ number_format($receiptStats['orders_zero_income']) }} + @php + $receiptMetricCards = [ + ['metric' => 'income_amount', 'label' => '账单收入', 'value' => $receiptStats['income_total']], + ['metric' => 'paid_amount', 'label' => '订单实付', 'value' => $receiptStats['paid_total'] ?? 0], + ['metric' => 'discount_amount', 'label' => '优惠收入', 'value' => $receiptStats['discount_total'] ?? 0], + ['metric' => 'offset_amount', 'label' => '冲减收入', 'value' => $receiptStats['offset_total'] ?? 0], + ['metric' => 'default_compensation_amount', 'label' => '违约赔付', 'value' => $receiptStats['default_compensation_total'] ?? 0], + ['metric' => 'refund_amount', 'label' => '退款金额', 'value' => $receiptStats['refund_total'] ?? 0], + ['metric' => 'planting_amount', 'label' => '种菜金额', 'value' => $receiptStats['planting_total'] ?? 0], + ['metric' => 'difference', 'label' => '差额合计', 'value' => $receiptStats['difference_total']], + ]; + @endphp +
+
+
+ 金额指标 + 点击任一指标,查看平台、店铺与账单项目明细;明细合计会与指标总额自动核对。 +
+ +
+
+ @foreach ($receiptMetricCards as $metricCard) + + @endforeach +
+
+ + +
+
+

指标明细

+

按平台、店铺和账单项目汇总

+
+
+
+ 当前筛选合计 + ¥0.00 +
+
-
-
金额构成
-
- 账单收入合计{{ number_format((float) $receiptStats['income_total'] / 10000, 2) }}万 - 订单实付金额合计{{ number_format((float) ($receiptStats['paid_total'] ?? 0) / 10000, 2) }}万 - 优惠金额合计{{ number_format((float) ($receiptStats['discount_total'] ?? 0) / 10000, 2) }}万 - 冲减金额合计{{ number_format((float) ($receiptStats['offset_total'] ?? 0) / 10000, 2) }}万 - 违约赔付(冲减收入)合计{{ number_format((float) ($receiptStats['default_compensation_total'] ?? 0) / 10000, 2) }}万 - 退款金额合计{{ number_format((float) ($receiptStats['refund_total'] ?? 0) / 10000, 2) }}万 - 种菜金额合计{{ number_format((float) ($receiptStats['planting_total'] ?? 0) / 10000, 2) }}万 - 差额合计{{ number_format((float) $receiptStats['difference_total'] / 10000, 2) }}万 -
+
+ + +
-
+
+ + +
+
+
+ + + + + + + + +
账单名称总金额
+
加载中…
+
+ +
+
+ 共 0 个账单名称 +
+
+ + +
+
+

业绩明细口径说明

+

仅在需要核对字段时查看,不占用主页面空间。

+
+ +
+
+

匹配口径

+
    +
  • 一行 = 一个店铺 + 一个线上订单号。
  • +
  • 订单实付取订单级实付,并扣除虚拟条码行实付;合单/拆单只归入一次。
  • +
  • 账单项目通过科目映射表转换为收入、优惠收入、冲减收入、违约赔付或退款。
  • +
+

金额关系

+
    +
  • 差额 = 收入 + 优惠收入 + 冲减收入 + 违约赔付 − 订单实付。
  • +
  • 退款金额、种菜金额单独展示,不参与差额计算。
  • +
  • 点击指标卡后,账单类指标按平台、店铺、账单名称汇总;明细合计应与指标总额一致。
  • +
+

状态说明

+
    +
  • “已匹配/未匹配”表示订单与账单是否找到对应关系。
  • +
  • “订单实付按卖家实收核对”“订单实付按应付/已付核对”是差额口径,不是未匹配异常。
  • +
+
+
@endif @if (!empty($directoryFields)) @@ -1985,6 +2284,281 @@ }); }); + @elseif (in_array(($page['slug'] ?? ''), ['inventory-report', 'monthly-weighted-average-price'], true))