From 4644146cce7f15dbc87a132b4dac02f5a49054c8 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Fri, 23 Oct 2026 14:48:37 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=BF=9D?= =?UTF-8?q?=E7=BA=A6=E8=B5=94=E4=BB=98=E6=9C=AC=E5=9C=B0=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...PreviewStoreReceiptDefaultCompensation.php | 62 ++++ app/Http/Controllers/Business/Pages.php | 50 ++- config/business_navigation.php | 1 + ...eiptDefaultCompensationSnapshotBuilder.php | 339 ++++++++++++++++++ tests/Feature/Settings/NavigationTest.php | 1 + 5 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/PreviewStoreReceiptDefaultCompensation.php create mode 100644 modules/EcommerceFinance/Services/StoreReceiptDefaultCompensationSnapshotBuilder.php diff --git a/app/Console/Commands/PreviewStoreReceiptDefaultCompensation.php b/app/Console/Commands/PreviewStoreReceiptDefaultCompensation.php new file mode 100644 index 00000000..feb006e6 --- /dev/null +++ b/app/Console/Commands/PreviewStoreReceiptDefaultCompensation.php @@ -0,0 +1,62 @@ +argument('month')); + $mapping = (string) $this->argument('mapping'); + + try { + $snapshot = $builder->build($month, $mapping); + $path = (string) ($this->option('output') ?: storage_path( + 'app/local/performance-details/default-compensation/' . $month . '.json' + )); + $directory = dirname($path); + if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) { + throw new \RuntimeException('无法创建快照目录:' . $directory); + } + $temporary = $path . '.tmp'; + $json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + if ($json === false || file_put_contents($temporary, $json, LOCK_EX) === false) { + throw new \RuntimeException('无法写入本地快照。'); + } + rename($temporary, $path); + + $this->info('本地预览快照已生成:' . $path); + $this->line('映射条数:' . $snapshot['mapping_rows']); + $this->line('关联行数:' . count($snapshot['amounts'])); + $this->line('金额合计:' . number_format((float) $snapshot['total'], 2, '.', '')); + $this->line('耗时:' . number_format((hrtime(true) - $startedAt) / 1e6, 0) . ' ms'); + $this->line('PHP 峰值内存:' . number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . ' MB'); + foreach ($snapshot['source_stats'] as $platform => $stats) { + $this->line(sprintf( + ' %s:账单行 %d,金额 %s', + $platform, + $stats['rows'], + number_format((float) $stats['amount'], 2, '.', ''), + )); + } + + return self::SUCCESS; + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 91976a87..c80a77ce 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1328,6 +1328,7 @@ class Pages extends Controller $unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason')); $differenceState = $this->receiptOrderMatchFilterValues(request('difference_state')); $orderNo = trim((string) request('online_order_no', '')); + $defaultCompensationAmounts = self::localReceiptDefaultCompensationAmounts($month); // 闭包里要用 $this->applyReceiptRemarkFilter()(差异原因备注按公式判定),所以不能声明为 static $applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $unmatchedReasonFilter, $differenceState): void { @@ -1479,8 +1480,9 @@ class Pages extends Controller // 差额异常备注列:只展示「差额为什么产生」的原因备注;未匹配原因单独一列; // 未匹配行(差额为空)按比对基准金额=0 计入差额。 - $items = $items->map(static function (object $row): object { + $items = $items->map(static function (object $row) use ($defaultCompensationAmounts): object { $rawRemark = trim((string) ($row->remark ?? '')); + $row->default_compensation_amount = $defaultCompensationAmounts[(string) ($row->row_key ?? '')] ?? null; // 未匹配原因列:未匹配行照旧;「订单实付按卖家实收核对」这类口径标识在已匹配行同样展示。 $row->unmatched_reason = $rawRemark === '' ? null @@ -1653,6 +1655,44 @@ class Pages extends Controller return array_keys($clean); } + /** + * 本地预览快照:读取按最新科目映射表生成的「违约赔付(冲减收入)」金额。 + * 文件不存在或内容无效时返回空数组,不影响正式页面。 + * + * @return array + */ + private static function localReceiptDefaultCompensationAmounts(?string $month): array + { + static $snapshots = []; + + $month = substr(trim((string) $month), 0, 7); + if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) { + return []; + } + if (isset($snapshots[$month])) { + return $snapshots[$month]; + } + + $path = storage_path('app/local/performance-details/default-compensation/' . $month . '.json'); + if (! is_file($path)) { + return $snapshots[$month] = []; + } + + $decoded = json_decode((string) file_get_contents($path), true); + if (! is_array($decoded) || ! is_array($decoded['amounts'] ?? null)) { + return $snapshots[$month] = []; + } + + $amounts = []; + foreach ($decoded['amounts'] as $rowKey => $amount) { + if (is_string($rowKey) && $rowKey !== '' && is_numeric($amount)) { + $amounts[$rowKey] = round((float) $amount, 2); + } + } + + return $snapshots[$month] = $amounts; + } + /** * 业绩明细的账期集合与当前账期:未指定月份时取镜像里最新的月份。 * @@ -2080,6 +2120,7 @@ class Pages extends Controller 'income_amount' => ['income_amount', '收入金额', 'money'], 'discount_amount' => ['discount_amount', '优惠金额', 'money'], 'offset_amount' => ['offset_amount', '冲减金额', 'money'], + 'default_compensation_amount' => ['default_compensation_amount', '违约赔付(冲减收入)', 'money'], 'refund_amount' => ['refund_amount', '退款金额', 'money'], 'planting_amount' => ['planting_amount', '种菜金额', 'money'], 'planting_label' => ['planting_label', '标签', 'text'], @@ -2091,6 +2132,7 @@ class Pages extends Controller ]; [, $month] = $this->receiptOrderMatchMonths(); + $defaultCompensationAmounts = self::localReceiptDefaultCompensationAmounts($month); $query = DB::connection($connection)->table('store_receipt_order_match_rows') ->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS) @@ -2158,7 +2200,7 @@ class Pages extends Controller $filename = '店铺收款匹配订单明细_' . ($month ?? 'all') . '.csv'; - return response()->streamDownload(function () use ($query, $selected, $map): void { + return response()->streamDownload(function () use ($query, $selected, $map, $defaultCompensationAmounts): void { // 单表几十万行:用游标一次查询顺序吐出行,避免 chunk 的 OFFSET 反复重跑 // (29 万行时会退化成几百次排序查询,导出要几分钟)。 @set_time_limit(0); @@ -2171,7 +2213,9 @@ class Pages extends Controller $line = []; foreach ($selected as $key) { $format = $map[$key][2]; - $value = $row->{$map[$key][0]} ?? ''; + $value = $key === 'default_compensation_amount' + ? ($defaultCompensationAmounts[(string) ($row->row_key ?? '')] ?? '') + : ($row->{$map[$key][0]} ?? ''); // 「差额异常备注」列与页面一致:只写差额产生的原因 if ($key === 'remark') { $value = self::receiptDifferenceRemark($row) ?? ''; diff --git a/config/business_navigation.php b/config/business_navigation.php index a993fc41..f71eaff0 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -832,6 +832,7 @@ $navigation = [ ['name' => 'income_amount', 'label' => '收入金额', 'kind' => 'number'], ['name' => 'discount_amount', 'label' => '优惠金额', 'kind' => 'number'], ['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'], + ['name' => 'default_compensation_amount', 'label' => '违约赔付(冲减收入)', 'kind' => 'number'], ['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'], ['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'], ['name' => 'planting_label', 'label' => '标签'], diff --git a/modules/EcommerceFinance/Services/StoreReceiptDefaultCompensationSnapshotBuilder.php b/modules/EcommerceFinance/Services/StoreReceiptDefaultCompensationSnapshotBuilder.php new file mode 100644 index 00000000..76b1eb1a --- /dev/null +++ b/modules/EcommerceFinance/Services/StoreReceiptDefaultCompensationSnapshotBuilder.php @@ -0,0 +1,339 @@ + [ + 'label' => '淘系支付宝', 'table' => 'taobao_alipay_bill_rows', + 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', + 'real_store' => 'online_real_store', + ], + 'taobao-wechat' => [ + 'label' => '淘系微信', 'table' => 'taobao_wechat_bill_rows', + 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', + ], + 'pinduoduo' => [ + 'label' => '拼多多', 'table' => 'pdd_settlement_bill_rows', + 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', + ], + 'dewu' => [ + 'label' => '得物', 'table' => 'dewu_settlement_bill_rows', + 'order' => 'online_order_no', 'time' => 'bill_date', 'amount' => 'income_amount', + 'real_store' => 'online_real_store', + ], + 'jd-transactions' => [ + 'label' => '京东账单', 'table' => 'jd_daily_bill_rows', + 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', + 'store_exclude' => '京喜', 'periods' => 'jd_daily_bill_periods', + ], + 'jingxi' => [ + 'label' => '京喜账单', 'table' => 'jd_daily_bill_rows', + 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', + 'store_include' => '京喜', 'periods' => 'jd_daily_bill_periods', + ], + 'douyin' => [ + 'label' => '抖音', 'table' => 'douyin_authoritative_bills', + 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', + 'periods' => 'douyin_authoritative_periods', + ], + ]; + + /** 页签内多平台按店铺名前缀拆分;单平台页签无需拆分。 */ + private const TAB_PLATFORM_GROUPS = [ + 'taobao-alipay' => [ + ['platform' => '淘宝', 'prefixes' => ['淘宝'], 'include_default' => true], + ['platform' => '天猫', 'prefixes' => ['天猫']], + ['platform' => '阿里巴巴', 'prefixes' => ['阿里巴巴', '1688', '新佰广']], + ], + 'taobao-wechat' => [ + ['platform' => '淘宝', 'prefixes' => ['淘宝'], 'include_default' => true], + ['platform' => '天猫', 'prefixes' => ['天猫']], + ], + 'pinduoduo' => [['platform' => '拼多多']], + 'dewu' => [['platform' => '得物']], + 'jd-transactions' => [['platform' => '京东']], + 'jingxi' => [['platform' => '京喜']], + 'douyin' => [['platform' => '抖音']], + ]; + + private const STORE_PREFIXES = [ + '唯品会JX', '唯品会MP', '阿里巴巴', '1688', '新佰广', + '拼多多', '天猫', '淘宝', '抖音', '得物', '京喜', '京东', + ]; + + /** + * @return array{ + * month:string,remark:string,generated_at:string,mapping_sha256:string,mapping_rows:int, + * source_stats:array, + * amounts:array,total:float + * } + */ + public function build(string $month, string $mappingPath): array + { + if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) { + throw new RuntimeException('月份格式必须是 YYYY-MM。'); + } + if (! is_file($mappingPath)) { + throw new RuntimeException('科目映射表不存在:' . $mappingPath); + } + + $start = CarbonImmutable::parse($month . '-01')->startOfMonth(); + $end = $start->addMonth(); + $reportMonth = $start->toDateString(); + $itemsByPlatform = $this->readMappingItems($mappingPath); + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $amounts = []; + $sourceStats = []; + + foreach (self::SOURCES as $tab => $source) { + $groups = $this->groupsForSource($tab, $itemsByPlatform); + if ($groups === []) { + continue; + } + + $table = (string) $source['table']; + $orderColumn = (string) $source['order']; + $timeColumn = (string) $source['time']; + $amountColumn = (string) $source['amount']; + $realStoreColumn = (string) ($source['real_store'] ?? ''); + $expenseColumn = ''; + if (Schema::connection($connection)->hasColumn($table, 'expense_amount')) { + $expenseColumn = 'expense_amount'; + } elseif (Schema::connection($connection)->hasColumn($table, 'expense')) { + $expenseColumn = 'expense'; + } + + $select = array_values(array_unique(array_filter([ + 'store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn, + $expenseColumn, $realStoreColumn, + ]))); + + $query = DB::connection($connection)->table($table) + ->select($select) + ->where($timeColumn, '>=', $start->toDateTimeString()) + ->where($timeColumn, '<', $end->toDateTimeString()); + + if (isset($source['store_include'])) { + $query->where('store_name', 'like', '%' . $source['store_include'] . '%'); + } + if (isset($source['store_exclude'])) { + $query->where('store_name', 'not like', '%' . $source['store_exclude'] . '%'); + } + + $periodsTable = (string) ($source['periods'] ?? ''); + if ($periodsTable !== '') { + $generations = DB::connection($connection)->table($periodsTable) + ->where('period_start', '<', $end->toDateString()) + ->where('period_end', '>', $start->toDateString()) + ->pluck('active_generation') + ->filter() + ->unique() + ->values() + ->all(); + if ($generations === []) { + continue; + } + $query->whereIn('generation', $generations); + } + + $query->where(function ($outer) use ($groups): void { + foreach ($groups as $group) { + $outer->orWhere(function ($sub) use ($group): void { + if ($group['prefixes'] !== []) { + $sub->where(function ($scope) use ($group): void { + $scope->where(function ($own) use ($group): void { + foreach ($group['prefixes'] as $prefix) { + $own->orWhere('store_name', 'like', $prefix . '%'); + } + }); + if ($group['include_default']) { + $scope->orWhere(function ($other): void { + foreach (self::STORE_PREFIXES as $prefix) { + $other->where('store_name', 'not like', $prefix . '%'); + } + }); + } + }); + } + $sub->whereIn('bill_item', $group['items']); + }); + } + }); + + $rows = 0; + $amountTotal = 0.0; + foreach ($query->orderBy($timeColumn)->cursor() as $row) { + $rows++; + $store = $realStoreColumn !== '' + ? trim((string) ($row->{$realStoreColumn} ?? '')) + : ''; + if ($store === '') { + $store = trim((string) ($row->store_name ?? '')); + } + $orderNo = trim((string) ($row->{$orderColumn} ?? '')); + $amount = (float) ($row->{$amountColumn} ?? 0) + - ($expenseColumn !== '' ? (float) ($row->{$expenseColumn} ?? 0) : 0.0); + $key = $this->rowKey( + $reportMonth, + (string) $source['label'], + $store, + $orderNo, + (string) ($row->bill_item ?? ''), + ); + $amounts[$key] = round(($amounts[$key] ?? 0.0) + $amount, 2); + $amountTotal += $amount; + } + + $sourceStats[(string) $source['label']] = [ + 'rows' => $rows, + 'amount' => round($amountTotal, 2), + 'groups' => count($groups), + ]; + } + + ksort($amounts); + + return [ + 'month' => $month, + 'remark' => self::REMARK, + 'generated_at' => now()->toIso8601String(), + 'mapping_sha256' => hash_file('sha256', $mappingPath), + 'mapping_rows' => array_sum(array_map('count', $itemsByPlatform)), + 'source_stats' => $sourceStats, + 'amounts' => $amounts, + 'total' => round(array_sum($amounts), 2), + ]; + } + + /** @return array> */ + private function readMappingItems(string $mappingPath): array + { + $sheet = IOFactory::load($mappingPath)->getActiveSheet(); + $rows = $sheet->toArray(null, true, true, false); + if ($rows === []) { + throw new RuntimeException('科目映射表没有数据。'); + } + + $headers = array_map(static fn ($value): string => trim((string) $value), array_shift($rows)); + $platformIndex = array_search('平台', $headers, true); + $itemIndex = array_search('账单项目', $headers, true); + $remarkIndex = array_search('财务备注', $headers, true); + if ($platformIndex === false || $itemIndex === false || $remarkIndex === false) { + throw new RuntimeException('科目映射表缺少「平台」「账单项目」或「财务备注」列。'); + } + + $items = []; + foreach ($rows as $row) { + if (trim((string) ($row[$remarkIndex] ?? '')) !== self::REMARK) { + continue; + } + $platform = trim((string) ($row[$platformIndex] ?? '')); + $item = trim((string) ($row[$itemIndex] ?? '')); + if ($platform !== '' && $item !== '') { + $items[$platform][$item] = true; + } + } + + $result = []; + foreach ($items as $platform => $values) { + $result[$platform] = array_keys($values); + sort($result[$platform], SORT_NATURAL | SORT_FLAG_CASE); + } + + return $result; + } + + /** + * @param array> $itemsByPlatform + * @return list,include_default:bool,items:list}> + */ + private function groupsForSource(string $tab, array $itemsByPlatform): array + { + $groups = []; + foreach (self::TAB_PLATFORM_GROUPS[$tab] ?? [] as $definition) { + $platform = (string) $definition['platform']; + $items = $itemsByPlatform[$platform] ?? []; + if ($items === []) { + continue; + } + if ($tab === 'taobao-wechat') { + $items = $this->withWxVariants($items); + } + $groups[] = [ + 'prefixes' => array_values((array) ($definition['prefixes'] ?? [])), + 'include_default' => (bool) ($definition['include_default'] ?? false), + 'items' => $items, + ]; + } + + return $groups; + } + + /** @param list $items @return list */ + private function withWxVariants(array $items): array + { + $variants = []; + foreach ($items as $item) { + $variants[$item] = true; + if (stripos($item, 'wx') === 0) { + $without = ltrim(mb_substr($item, 2)); + if ($without !== '') { + $variants[$without] = true; + } + } else { + $variants['wx' . $item] = true; + } + } + $list = array_keys($variants); + sort($list, SORT_NATURAL | SORT_FLAG_CASE); + + return $list; + } + + private function rowKey(string $month, string $platform, string $storeName, string $orderNo, string $billItem): string + { + $shopKey = $this->normalizeShop($storeName); + $identity = $this->orderIdentity($orderNo); + if ($identity === '') { + return md5($month . '|' . $platform . '|' . $shopKey . '|promo|' . $billItem); + } + + return md5($month . '|' . $platform . '|' . $shopKey . '|' . $identity); + } + + private function normalizeShop(string $value): string + { + $value = str_replace(["\u{3000}", '_'], [' ', '-'], trim($value)); + $value = preg_replace('/\s+/u', '', $value) ?? ''; + + return strtoupper($value); + } + + private function orderIdentity(string $value): string + { + $value = str_replace("\u{3000}", ' ', trim($value)); + if ($value === '') { + return ''; + } + $value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value; + $value = preg_replace('/\s+/u', '', $value) ?? ''; + + return strtoupper($value); + } +} diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index fd9c36cb..b8217968 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -100,6 +100,7 @@ class NavigationTest extends FeatureTestCase ->assertSee('data-column-key="offset_amount"', false) ->assertSee('data-column-key="planting_amount"', false) ->assertSee('data-column-key="planting_label"', false) + ->assertSee('data-column-key="default_compensation_amount"', false) ->assertSee('data-column-key="product_amount"', false) ->assertSee('data-column-key="difference"', false) ->assertSee('data-column-key="match_status"', false) -- 2.47.2 From 5dbf4ecfa6ea8613b03dab3f70c39614cb2179b5 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Fri, 23 Oct 2026 15:36:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=A7=91?= =?UTF-8?q?=E7=9B=AE=E6=98=A0=E5=B0=84=E6=9C=AC=E5=9C=B0=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E9=87=8D=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/SyncStoreReceiptOrderMatches.php | 6 ++- .../Services/AccountLedgerRemarkMap.php | 25 +++++++++- ...StoreReceiptMappingPreviewOverrideTest.php | 47 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/EcommerceFinance/StoreReceiptMappingPreviewOverrideTest.php diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index 661b6647..2c512a78 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -121,9 +121,13 @@ class SyncStoreReceiptOrderMatches extends Command '优惠收入', ]; - /** 「冲减金额」列口径:账单的总账备注命中以下取值,不参与收入金额与差额计算。 */ + /** + * 「冲减金额」列口径:账单的总账备注命中以下取值,不参与收入金额与差额计算。 + * 「违约赔付(冲减收入)」是冲减收入的细分项,总额仍计入冲减金额。 + */ private const OFFSET_REMARKS = [ '冲减收入', + '违约赔付(冲减收入)', ]; /** 「退款金额」列口径:账单的总账备注命中以下取值,单独列示,不参与收入金额与差额计算。 */ diff --git a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php index fd33d349..883b2440 100644 --- a/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php +++ b/modules/EcommerceFinance/Services/AccountLedgerRemarkMap.php @@ -466,7 +466,9 @@ class AccountLedgerRemarkMap $index = []; try { - foreach (app(AccountMappingStore::class)->all() as $rule) { + $rules = $this->accountMappingOverrideRules() + ?? app(AccountMappingStore::class)->all(); + foreach ($rules as $rule) { if (! is_array($rule)) { continue; } @@ -485,6 +487,27 @@ class AccountLedgerRemarkMap return $this->accountMappingIndex = $index; } + /** + * 本地模拟映射覆盖:仅在显式设置 ACCOUNT_MAPPING_OVERRIDE_FILE 时启用。 + * 正常页面和定时任务不设置该变量,继续读取共享系统的科目映射表。 + * + * @return list>|null + */ + private function accountMappingOverrideRules(): ?array + { + $path = trim((string) (getenv('ACCOUNT_MAPPING_OVERRIDE_FILE') ?: '')); + if ($path === '' || ! is_file($path)) { + return null; + } + + $decoded = json_decode((string) file_get_contents($path), true); + if (! is_array($decoded) || ! array_is_list($decoded)) { + throw new \RuntimeException('本地科目映射覆盖文件格式无效:' . $path); + } + + return $decoded; + } + /** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 null(由调用方回落)。 */ private function accountMappingRemark(string $platform, string $item, array $businessRemarks, string $goodsName): ?string { diff --git a/tests/Unit/EcommerceFinance/StoreReceiptMappingPreviewOverrideTest.php b/tests/Unit/EcommerceFinance/StoreReceiptMappingPreviewOverrideTest.php new file mode 100644 index 00000000..38e4a8a4 --- /dev/null +++ b/tests/Unit/EcommerceFinance/StoreReceiptMappingPreviewOverrideTest.php @@ -0,0 +1,47 @@ +getConstant('OFFSET_REMARKS'); + + $this->assertContains('冲减收入', $remarks); + $this->assertContains('违约赔付(冲减收入)', $remarks); + } + + public function test_local_mapping_override_file_is_used_when_configured(): void + { + $path = tempnam(sys_get_temp_dir(), 'caiwu-mapping-override-'); + $this->assertNotFalse($path); + file_put_contents($path, json_encode([ + ['platform' => '京东', 'item' => '结算单', 'memo' => '订单收入'], + ['platform' => '京东', 'item' => '售后卖家赔付费', 'memo' => '违约赔付(冲减收入)'], + ], JSON_UNESCAPED_UNICODE)); + + $previous = getenv('ACCOUNT_MAPPING_OVERRIDE_FILE'); + putenv('ACCOUNT_MAPPING_OVERRIDE_FILE=' . $path); + + try { + $method = new ReflectionMethod(AccountLedgerRemarkMap::class, 'accountMappingOverrideRules'); + $rules = $method->invoke(new AccountLedgerRemarkMap()); + + $this->assertCount(2, $rules); + $this->assertSame('违约赔付(冲减收入)', $rules[1]['memo']); + } finally { + putenv($previous === false + ? 'ACCOUNT_MAPPING_OVERRIDE_FILE' + : 'ACCOUNT_MAPPING_OVERRIDE_FILE=' . $previous); + @unlink($path); + } + } +} -- 2.47.2 From cb158d5c99c3d2f6c6886467f1489cc7ddadb0be Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Fri, 23 Oct 2026 18:11:53 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E4=B8=9A=E7=BB=A9?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E8=BF=9D=E7=BA=A6=E8=B5=94=E4=BB=98=E5=88=97?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Feature/Settings/NavigationTest.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index b8217968..20068c09 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -83,7 +83,7 @@ class NavigationTest extends FeatureTestCase public function testPerformanceDetailsPageIsAvailable(): void { - $this->loginAs() + $response = $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'financial-operations', 'page' => 'performance-details', @@ -121,6 +121,17 @@ class NavigationTest extends FeatureTestCase ->assertSee('name="match_status"', false) ->assertSee('/workspace/financial-operations/performance-details/export', false) ->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。'); + + $html = (string) $response->getContent(); + $offsetPosition = strpos($html, 'data-column-key="offset_amount"'); + $compensationPosition = strpos($html, 'data-column-key="default_compensation_amount"'); + $refundPosition = strpos($html, 'data-column-key="refund_amount"'); + + $this->assertNotFalse($offsetPosition); + $this->assertNotFalse($compensationPosition); + $this->assertNotFalse($refundPosition); + $this->assertLessThan($compensationPosition, $offsetPosition); + $this->assertLessThan($refundPosition, $compensationPosition); } public function testFinancialRiskControlPageIsAvailableBetweenFinancialReports(): void -- 2.47.2 From f973153f762fa1b3ff242ded52b1c8abbc539bca Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Fri, 23 Oct 2026 18:49:03 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E8=B4=A2=E5=8A=A1=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=8C=89=E5=BC=80=E7=A5=A8=E5=8F=A3=E5=BE=84=E6=8B=86?= =?UTF-8?q?=E5=88=86=E8=B4=B9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 49 +++- .../View/BillingDetailsPageData.php | 210 +++++++++++++++++- .../financial-analysis.blade.php | 13 +- tests/Feature/Settings/NavigationTest.php | 29 ++- 4 files changed, 281 insertions(+), 20 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index c80a77ce..26dfef09 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -48,6 +48,7 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; +use Modules\EcommerceFinance\View\BillingDetailsPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository; use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; @@ -2398,6 +2399,7 @@ class Pages extends Controller * 收入 = 业绩明细里(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和 * (冲减、退款取数本身带负号,直接相加即为净收入); * 成本 = 商品成本表的商品成本汇总;运费暂不取数,列上留空。 + * 费用 = 店铺收款账户账单中财务类型为「费用」的支出减收入,按票据口径拆为开票 / 不开票。 */ protected function contentForFinancialAnalysis(): array { @@ -2441,11 +2443,15 @@ class Pages extends Controller $incomeTotal = 0.0; $costTotal = 0.0; + $invoiceExpenseTotal = 0.0; + $nonInvoiceExpenseTotal = 0.0; /** @var array $platformOptions */ $platformOptions = []; foreach ($rows as $row) { $incomeTotal += (float) $row['income']; $costTotal += (float) $row['cost']; + $invoiceExpenseTotal += (float) $row['invoice_expense']; + $nonInvoiceExpenseTotal += (float) $row['non_invoice_expense']; if ($row['platform'] !== '') { $platformOptions[(string) $row['platform']] = true; } @@ -2466,6 +2472,8 @@ class Pages extends Controller 'rows' => count($rows), 'income_total' => round($incomeTotal, 2), 'cost_total' => round($costTotal, 2), + 'invoice_expense_total' => round($invoiceExpenseTotal, 2), + 'non_invoice_expense_total' => round($nonInvoiceExpenseTotal, 2), ], 'financialAnalysisMonths' => $months, 'financialAnalysisPlatforms' => $platformOptions, @@ -2479,7 +2487,7 @@ class Pages extends Controller * 页面与导出共用,保证两边口径一致。 * * @param array{month:string,platform:string,store_name:string} $filters - * @return list + * @return list */ private function financialAnalysisRows(array $filters): array { @@ -2534,14 +2542,14 @@ class Pages extends Controller * * 两张表都是几十万行,聚合结果按月份缓存 5 分钟;筛选在内存里做,避免每次开页重跑分组。 * - * @return array + * @return array */ private function financialAnalysisAggregates(string $reportMonth): array { $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); return cache()->remember( - 'financial-analysis-aggregates-v1:' . $reportMonth, + 'financial-analysis-aggregates-v3:' . $reportMonth . ':' . $this->financialAnalysisMappingSignature(), now()->addMinutes(5), static function () use ($connection, $reportMonth): array { $rows = []; @@ -2559,9 +2567,27 @@ class Pages extends Controller 'store_name' => (string) $row->store_name, 'income' => round((float) $row->income, 2), 'cost' => 0.0, + 'invoice_expense' => 0.0, + 'non_invoice_expense' => 0.0, ]; } + $expenseBuckets = app(BillingDetailsPageData::class) + ->expenseBucketsForMonth(substr($reportMonth, 0, 7)); + foreach ($expenseBuckets as $bucket) { + $key = $bucket['platform'] . "\0" . $bucket['store_name']; + $rows[$key] ??= [ + 'platform' => $bucket['platform'], + 'store_name' => $bucket['store_name'], + 'income' => 0.0, + 'cost' => 0.0, + 'invoice_expense' => 0.0, + 'non_invoice_expense' => 0.0, + ]; + $rows[$key]['invoice_expense'] = round((float) $bucket['invoice_expense'], 2); + $rows[$key]['non_invoice_expense'] = round((float) $bucket['non_invoice_expense'], 2); + } + $costRows = DB::connection($connection)->table('store_receipt_product_cost_rows') ->where('report_month', $reportMonth) ->selectRaw('platform, store_name, SUM(COALESCE(product_cost, 0)) as cost') @@ -2574,6 +2600,8 @@ class Pages extends Controller 'store_name' => $rows[$key]['store_name'] ?? (string) $row->store_name, 'income' => $rows[$key]['income'] ?? 0.0, 'cost' => round((float) $row->cost, 2), + 'invoice_expense' => $rows[$key]['invoice_expense'] ?? 0.0, + 'non_invoice_expense' => $rows[$key]['non_invoice_expense'] ?? 0.0, ]; } @@ -2582,6 +2610,17 @@ class Pages extends Controller ); } + /** 科目映射变化时自动换缓存键,避免费用拆票结果在 5 分钟缓存内继续使用旧口径。 */ + private function financialAnalysisMappingSignature(): string + { + $rules = app(AccountMappingStore::class)->all(); + + return substr(hash( + 'sha256', + (string) json_encode($rules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ), 0, 16); + } + /** * 财务经营分析表导出:按当前筛选条件导出「时间 / 平台 / 店铺 / 收入 / 成本 / 运费」。 */ @@ -2607,7 +2646,7 @@ class Pages extends Controller @set_time_limit(0); $out = fopen('php://output', 'w'); fwrite($out, "\xEF\xBB\xBF"); - fputcsv($out, ['时间', '平台', '店铺', '收入', '成本', '运费']); + fputcsv($out, ['时间', '平台', '店铺', '收入', '成本', '运费', '开票费用', '不开票费用']); foreach ($rows as $row) { fputcsv($out, [ $row['month'], @@ -2617,6 +2656,8 @@ class Pages extends Controller number_format((float) $row['cost'], 2, '.', ''), // 运费暂不取数,先留空列,等口径确认后补。 '', + number_format((float) $row['invoice_expense'], 2, '.', ''), + number_format((float) $row['non_invoice_expense'], 2, '.', ''), ]); } fclose($out); diff --git a/modules/EcommerceFinance/View/BillingDetailsPageData.php b/modules/EcommerceFinance/View/BillingDetailsPageData.php index 4d8220e3..802a2aa2 100644 --- a/modules/EcommerceFinance/View/BillingDetailsPageData.php +++ b/modules/EcommerceFinance/View/BillingDetailsPageData.php @@ -42,6 +42,16 @@ class BillingDetailsPageData '唯品会MP' => '唯品会MP', '唯品会JX' => '唯品会JX', '支付宝' => '支付宝', ]; + /** 账单来源页签 → 财务经营分析表使用的平台列。 */ + private const ACCOUNT_LABELS = [ + '淘系支付宝' => '淘系支付宝', + '淘系微信' => '淘系微信', + '拼多多' => '拼多多', + '京东钱包' => '京东网银钱包', + '抖音' => '抖音', + '得物' => '得物', + ]; + /** * 平台筛选:按「账户 / 数据来源」分类,与「店铺收款账户账单」各页签一致。 * source 对应 self::SOURCES 的分支;prefix 非空时再按店铺前缀切分(京东账单 / 京喜账单同属一张表)。 @@ -330,6 +340,193 @@ class BillingDetailsPageData ]; } + /** + * 财务类型为「费用」的账单金额,按财务经营分析表的「平台-店铺」及票据口径汇总。 + * + * 金额口径为账单「支出 - 收入」,费用返还/退回以负数抵减费用。 + * 票据口径 0.06 / 0.09 归入开票费用,不开票归入不开票费用。 + * + * @return array + */ + public function expenseBucketsForMonth(string $reportMonth): array + { + if (preg_match('/^\d{4}-\d{2}$/', $reportMonth) !== 1) { + return []; + } + + $filters = $this->parseFilters(['month' => $reportMonth]); + $filters['platform'] = []; + $filters['store'] = []; + $filters['item'] = []; + $filters['type'] = []; + $filters['remark'] = []; + $filters['keyword'] = ''; + + $connection = $this->connection(); + $buckets = []; + $rulesByPlatform = $this->expenseRulesByPlatform(); + + foreach (self::SOURCES as $label => $meta) { + $branch = $this->branch($label, $meta, $filters); + $conditions = ['invoice_expense' => [], 'non_invoice_expense' => []]; + foreach (self::BRANCH_PLATFORMS[$label] ?? [] as $platform) { + foreach ($conditions as $field => $_) { + $items = array_keys($rulesByPlatform[$platform][$field] ?? []); + if ($items === []) { + continue; + } + $conditions[$field][] = [ + 'sql' => '(' . $this->storePrefixCondition($platform) + . ' AND bill_item IN (' . $this->literals($this->expenseItemVariants($items)) . '))', + 'account' => $this->accountLabelForPlatform($label, $platform), + ]; + } + } + if ($conditions['invoice_expense'] === [] && $conditions['non_invoice_expense'] === []) { + continue; + } + + $amount = 'COALESCE(' . $branch['expense'] . ', 0) - COALESCE(' . $branch['income'] . ', 0)'; + + foreach ($branch['scopes'] as $scope) { + $parts = []; + $bind = []; + foreach ($conditions as $field => $items) { + foreach ($items as $condition) { + $parts[] = 'SELECT ' . $this->literal((string) $condition['account']) . ' AS account_label, store_name, ' + . ($field === 'invoice_expense' ? 'COALESCE(SUM(' . $amount . '), 0)' : '0') . ' AS invoice_expense, ' + . ($field === 'non_invoice_expense' ? 'COALESCE(SUM(' . $amount . '), 0)' : '0') . ' AS non_invoice_expense ' + . 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where'] + . ' AND ' . $condition['sql'] . ' GROUP BY store_name'; + $bind = array_merge($bind, $scope['bind']); + } + } + $rows = $connection->select( + 'SELECT account_label, store_name, ' + . 'COALESCE(SUM(invoice_expense), 0) AS invoice_expense, ' + . 'COALESCE(SUM(non_invoice_expense), 0) AS non_invoice_expense ' + . 'FROM (' . implode(' UNION ALL ', $parts) . ') expense_rows ' + . 'GROUP BY account_label, store_name', + $bind, + ); + foreach ($rows as $row) { + $store = trim((string) ($row->store_name ?? '')); + $account = trim((string) ($row->account_label ?? '')); + if ($store === '' || $account === '') { + continue; + } + $key = $account . "\0" . $store; + $buckets[$key] ??= [ + 'platform' => $account, + 'store_name' => $store, + 'invoice_expense' => 0.0, + 'non_invoice_expense' => 0.0, + ]; + $buckets[$key]['invoice_expense'] = round( + (float) $buckets[$key]['invoice_expense'] + (float) $row->invoice_expense, + 2, + ); + $buckets[$key]['non_invoice_expense'] = round( + (float) $buckets[$key]['non_invoice_expense'] + (float) $row->non_invoice_expense, + 2, + ); + } + } + } + + foreach ($buckets as &$bucket) { + $bucket['invoice_expense'] = round((float) $bucket['invoice_expense'], 2); + $bucket['non_invoice_expense'] = round((float) $bucket['non_invoice_expense'], 2); + } + unset($bucket); + + return $buckets; + } + + private function accountLabelForPlatform(string $source, string $platform): string + { + if ($source === '京东账单') { + return $platform === '京喜' ? '京喜账单' : '京东账单'; + } + + return self::ACCOUNT_LABELS[$source] ?? $source; + } + + /** + * 财务类型为「费用」的规则,按业务平台和票据口径分组。 + * + * 当前科目映射保证同一平台同一账单项目的「费用」规则票据口径一致; + * 若以后出现同项目既开票又不开票,直接报错,避免静默算错。 + * + * @return array,non_invoice_expense:array}> + */ + private function expenseRulesByPlatform(): array + { + $itemMeta = []; + $feeRules = []; + foreach (app(AccountMappingStore::class)->all() as $rule) { + $platform = trim((string) ($rule['platform'] ?? '')); + $item = $this->normalizeItem((string) ($rule['item'] ?? '')); + $category = trim((string) ($rule['category'] ?? '')); + $invoice = trim((string) ($rule['invoice'] ?? '')); + if ($platform === '' || $item === '') { + continue; + } + + $key = mb_strtolower($platform) . "\0" . mb_strtolower($item); + $itemMeta[$key]['categories'][$category] = true; + $itemMeta[$key]['invoices'][$invoice] = true; + if ($category === '费用') { + $feeRules[] = [$platform, $item, $invoice]; + } + } + + foreach ($feeRules as [$platform, $item, $invoice]) { + $item = $this->normalizeItem($item); + $meta = $itemMeta[mb_strtolower($platform) . "\0" . mb_strtolower($item)] ?? []; + if (count($meta['categories'] ?? []) > 1 || count($meta['invoices'] ?? []) > 1) { + throw new \RuntimeException( + '费用票据口径无法唯一确定:' . $platform . ' / ' . $item, + ); + } + + $bucket = match ($invoice) { + '0.06', '0.09' => 'invoice_expense', + '不开票' => 'non_invoice_expense', + default => throw new \RuntimeException( + '费用票据口径未支持:' . $platform . ' / ' . $item . ' / ' . $invoice, + ), + }; + $groups[$platform][$bucket][$item] = true; + } + + return $groups ?? []; + } + + /** + * 科目映射表里的账单项目同时生成带 / 不带 wx 前缀的查找值, + * 保留各账单表的 bill_item 索引,避免对整月数据执行 SUBSTRING。 + * + * @param list $items + * @return list + */ + private function expenseItemVariants(array $items): array + { + $variants = []; + foreach ($items as $item) { + $item = trim((string) $item); + if ($item === '') { + continue; + } + $variants[$item] = true; + $variants[preg_match('/^wx/i', $item) === 1 ? $this->normalizeItem($item) : 'wx' . $item] = true; + } + $variants = array_keys($variants); + sort($variants); + + return $variants; + } + /** * 筛选联动:按「除该筛选项以外的其它条件」统计各筛选项的可选值与行数,行数为 0 的取值不返回。 * 结果按筛选条件签名缓存 60 秒,避免连续勾选时反复扫同一批数据。 @@ -594,7 +791,7 @@ class BillingDetailsPageData /** * 账单项目 → [平台 => ['memo'=>..,'category'=>..]] 预索引,避免逐项遍历全部规则。 * - * @param array $rules + * @param array $rules * @return array> */ private function ruleIndexByItem(array $rules): array @@ -896,6 +1093,7 @@ class BillingDetailsPageData $row['platform'] = $platform; $row['finance_remark'] = (string) ($rule['memo'] ?? ''); $row['finance_type'] = (string) ($rule['category'] ?? ''); + $row['finance_invoice'] = (string) ($rule['invoice'] ?? ''); $row['matched'] = $rule !== null; // 同一账单项目多条规则时按业务备注/商品名称关键词取最具体的一条(与店铺收款账户账单页同一口径) @@ -921,6 +1119,7 @@ class BillingDetailsPageData if ($matched !== null) { $row['finance_remark'] = (string) ($matched['rule']['memo'] ?? ''); $row['finance_type'] = (string) ($matched['rule']['category'] ?? ''); + $row['finance_invoice'] = (string) ($matched['rule']['invoice'] ?? ''); $row['matched'] = true; } } @@ -1140,7 +1339,7 @@ class BillingDetailsPageData return $this->columnCache[$table]; } - /** 科目映射表:平台|账单项目 → [memo, category](共享数据库,随页面请求实时读取) */ + /** 科目映射表:平台|账单项目 → [memo, category, invoice](共享数据库,随页面请求实时读取) */ private function rules(): array { static $cache = null; @@ -1156,7 +1355,12 @@ class BillingDetailsPageData $prev = $cache[$key] ?? null; $isCatchAll = trim((string) ($rule['memo'] ?? '')) === '' && trim((string) ($rule['goods'] ?? '')) === ''; if ($prev === null || ($isCatchAll && ! $prev['catch'])) { - $cache[$key] = ['memo' => (string) ($rule['memo'] ?? ''), 'category' => (string) ($rule['category'] ?? ''), 'catch' => $isCatchAll]; + $cache[$key] = [ + 'memo' => (string) ($rule['memo'] ?? ''), + 'category' => (string) ($rule['category'] ?? ''), + 'invoice' => (string) ($rule['invoice'] ?? ''), + 'catch' => $isCatchAll, + ]; } } return $cache; diff --git a/resources/views/business/pages/financial-operations/financial-analysis.blade.php b/resources/views/business/pages/financial-operations/financial-analysis.blade.php index 5629516a..08a10dd9 100644 --- a/resources/views/business/pages/financial-operations/financial-analysis.blade.php +++ b/resources/views/business/pages/financial-operations/financial-analysis.blade.php @@ -27,7 +27,7 @@ .financial-analysis-summary .item span { color:var(--vip-content-muted,#595959); font-size:.7rem; } .financial-analysis-summary .item b { font-size:.95rem; font-variant-numeric:tabular-nums; } .financial-analysis-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; } - .financial-analysis-table { width:100%; min-width:56rem; border-collapse:separate; border-spacing:0; font-size:.78rem; } + .financial-analysis-table { width:100%; min-width:68rem; border-collapse:separate; border-spacing:0; font-size:.78rem; } .financial-analysis-table th,.financial-analysis-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; } .financial-analysis-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; } .financial-analysis-table tr:hover td { background:#fcfaf4; } @@ -75,7 +75,8 @@
口径:一行 = 一个月的一个平台的一个店铺。收入 = 业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」, 按各字段自身的记账符号求和(冲减、退款在取数时已是负数,相加即为净收入); - 成本 = 商品成本表按店铺汇总的商品成本(四舍五入两位小数);运费暂不取数,列上留空。 + 成本 = 商品成本表按店铺汇总的商品成本(四舍五入两位小数);运费暂不取数,列上留空; + 开票费用 / 不开票费用 = 店铺收款账户账单中财务类型为「费用」的支出减收入,按科目映射表票据口径 0.06 / 0.09 与不开票分别汇总。
@@ -83,6 +84,8 @@
收入合计{{ $money($summary['income_total'] ?? null) }}
成本合计{{ $money($summary['cost_total'] ?? null) }}
运费合计暂不取数
+
开票费用合计{{ $money($summary['invoice_expense_total'] ?? null) }}
+
不开票费用合计{{ $money($summary['non_invoice_expense_total'] ?? null) }}
@@ -94,6 +97,8 @@ 收入 成本 运费 + 开票费用 + 不开票费用 @forelse ($rows ?? [] as $row) @@ -104,9 +109,11 @@ {{ $money($row['income'] ?? null) }} {{ $money($row['cost'] ?? null) }} — + {{ $money($row['invoice_expense'] ?? null) }} + {{ $money($row['non_invoice_expense'] ?? null) }} @empty -
+
{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的财务经营分析数据;请确认业绩明细与商品成本已生成。' }}
@endforelse diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 20068c09..57fbd4f9 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -83,7 +83,7 @@ class NavigationTest extends FeatureTestCase public function testPerformanceDetailsPageIsAvailable(): void { - $response = $this->loginAs() + $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'financial-operations', 'page' => 'performance-details', @@ -121,17 +121,26 @@ class NavigationTest extends FeatureTestCase ->assertSee('name="match_status"', false) ->assertSee('/workspace/financial-operations/performance-details/export', false) ->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。'); + } - $html = (string) $response->getContent(); - $offsetPosition = strpos($html, 'data-column-key="offset_amount"'); - $compensationPosition = strpos($html, 'data-column-key="default_compensation_amount"'); - $refundPosition = strpos($html, 'data-column-key="refund_amount"'); + public function testFinancialAnalysisPageIncludesInvoiceExpenseColumns(): void + { + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'financial-operations', + 'page' => 'financial-analysis', + ])) + ->assertOk() + ->assertSeeTextInOrder(['收入', '成本', '运费', '开票费用', '不开票费用']) + ->assertSeeText('开票费用合计') + ->assertSeeText('不开票费用合计') + ->assertSee('/workspace/financial-operations/financial-analysis/export', false); - $this->assertNotFalse($offsetPosition); - $this->assertNotFalse($compensationPosition); - $this->assertNotFalse($refundPosition); - $this->assertLessThan($compensationPosition, $offsetPosition); - $this->assertLessThan($refundPosition, $compensationPosition); + $csv = $this->loginAs() + ->get(route('business.financial-operations.financial-analysis.export')) + ->streamedContent(); + + $this->assertStringContainsString('时间,平台,店铺,收入,成本,运费,开票费用,不开票费用', $csv); } public function testFinancialRiskControlPageIsAvailableBetweenFinancialReports(): void -- 2.47.2