From a071e4b9eb7e3e0e294a398c00de44a172848eec Mon Sep 17 00:00:00 2001 From: chenxi Date: Thu, 13 Aug 2026 11:25:07 +0800 Subject: [PATCH 1/2] chore: reserve marketing balance restoration -- 2.47.2 From 2506749570ade88d73bab291e2991ea1334d4a65 Mon Sep 17 00:00:00 2001 From: chenxi Date: Thu, 13 Aug 2026 11:39:29 +0800 Subject: [PATCH 2/2] fix: restore marketing account balances on main --- app/Http/Controllers/Business/Pages.php | 127 ++++++++++++++++++ config/business_navigation.php | 14 +- .../PinduoduoSpreadBillRepository.php | 121 +++++++++++++++++ .../TmallTaobaoSpreadBillRepository.php | 123 +++++++++++++++++ .../Services/JianSunSnapshotRefresher.php | 40 +++--- .../partials/embedded-table.blade.php | 106 +++++++++++++++ resources/views/business/pages/show.blade.php | 3 + routes/admin.php | 3 + .../MarketingAccountBillsTabsTest.php | 57 ++++++++ .../JianSunSnapshotRefresherTest.php | 54 ++++++++ .../PinduoduoSpreadBillRepositoryTest.php | 45 +++++++ .../TmallTaobaoSpreadBillRepositoryTest.php | 47 +++++++ 12 files changed, 722 insertions(+), 18 deletions(-) create mode 100644 resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index f9505b87..ffa8e6bb 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -232,6 +232,7 @@ class Pages extends Controller { return match ($page) { 'marketing-account-bills' => $this->contentForMarketingAccountBills($tab), + 'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab), 'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(), 'product-prices' => $this->contentForProductPrices(), 'board-deductions' => $this->contentForBoardDeductions(), @@ -240,6 +241,132 @@ class Pages extends Controller }; } + protected function contentForMarketingAccountBalance(?string $tab): array + { + $content = [ + 'view' => 'banking.marketing_account_balances.partials.embedded-table', + 'platform' => $tab, + 'balances' => collect(), + 'error' => null, + ]; + + if (! in_array($tab, ['taobao-alipay', 'pinduoduo'], true)) { + return $content; + } + + $request = request(); + $page = max(1, (int) $request->query('page', 1)); + $pageSize = min(max((int) setting('default.list_limit', 25), 1), 100); + $storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191); + $month = trim((string) $request->query('month', '')); + $month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : ''; + $filters = [ + 'store_name' => $storeName !== '' ? $storeName : null, + 'month' => $month !== '' ? $month : null, + ]; + $isTaobao = $tab === 'taobao-alipay'; + $repository = $isTaobao + ? app(TmallTaobaoSpreadBillRepository::class) + : app(PinduoduoSpreadBillRepository::class); + $scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list'; + $cache = $this->upstreamCaches->forSource('jiansun'); + + try { + $result = $cache->remember( + $scope, + ['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize], + fn () => $repository->paginateMonthlyBalances( + $filters['store_name'], + $filters['month'], + $page, + $pageSize, + ), + ); + } catch (ServiceUnavailableHttpException $exception) { + $content['error'] = $exception->getMessage(); + $result = ['rows' => [], 'total' => 0]; + } catch (Throwable $exception) { + report($exception); + $content['error'] = ($isTaobao ? '淘系' : '拼多多') . '账户余额数据暂时无法读取,请稍后重试。'; + $result = ['rows' => [], 'total' => 0]; + } + + try { + $storeOptions = $cache->remember( + $isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options', + [], + fn () => $repository->storeOptions(), + ); + } catch (Throwable $exception) { + report($exception); + $storeOptions = []; + } + + $content['balances'] = new LengthAwarePaginator( + Collection::make($result['rows'] ?? []), + (int) ($result['total'] ?? 0), + $pageSize, + $page, + ['path' => $request->url(), 'query' => $request->query()], + ); + $content['filters'] = ['store_name' => $storeName, 'month' => $month]; + $content['storeOptions'] = $storeOptions; + + return $content; + } + + public function export(Request $request, string $section, string $page, string $tab) + { + abort_unless($section === 'billing-transactions', 404); + + $sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section); + abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。'); + $pageConfig = collect($sectionConfig['pages'] ?? [])->firstWhere('slug', $page); + abort_if(empty($pageConfig), 403, '当前账号没有该页面权限。'); + abort_unless($page === 'marketing-account-balance', 404); + abort_unless(in_array($tab, ['taobao-alipay', 'pinduoduo'], true), 404); + + $storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191); + $month = trim((string) $request->query('month', '')); + $month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : ''; + $filters = [ + 'store_name' => $storeName !== '' ? $storeName : null, + 'month' => $month !== '' ? $month : null, + ]; + $isTaobao = $tab === 'taobao-alipay'; + $repository = $isTaobao + ? app(TmallTaobaoSpreadBillRepository::class) + : app(PinduoduoSpreadBillRepository::class); + $scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list'; + $result = $this->upstreamCaches->forSource('jiansun')->remember( + $scope, + ['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true], + fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000), + ); + $rows = $result['rows'] ?? []; + + return response()->streamDownload(function () use ($rows): void { + $csv = fopen('php://output', 'w'); + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, [ + '日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额', + '系统期末余额', '余额差异', '差异原因', '核对状态', '备注', + ]); + + foreach ($rows as $row) { + fputcsv($csv, [ + $row['date'], $row['store_name'], $row['opening_balance'], $row['income_total'], + $row['expense_total'], $row['closing_balance'], $row['system_closing_balance'], + $row['balance_difference'], $row['difference_reason'], $row['verification_status'], $row['notes'], + ]); + } + + fclose($csv); + }, ($isTaobao ? 'taobao' : 'pinduoduo') . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + protected function contentForBoardDeductions(): array { $filters = [ diff --git a/config/business_navigation.php b/config/business_navigation.php index 702d1808..6badb7da 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -79,7 +79,19 @@ return [ ['slug' => 'dewu', 'title' => '得物账户'], ], ], - ['slug' => 'marketing-account-balance', 'title' => '店铺营销账户余额'], + [ + 'slug' => 'marketing-account-balance', + 'title' => '店铺营销账户余额', + 'tabs' => [ + ['slug' => 'taobao-alipay', 'title' => '淘系账户'], + ['slug' => 'taobao-wechat', 'title' => '阿里巴巴账户'], + ['slug' => 'pinduoduo', 'title' => '拼多多账户'], + ['slug' => 'jd-wallet', 'title' => '京东账户'], + ['slug' => 'douyin', 'title' => '抖音账户'], + ['slug' => 'dewu', 'title' => '得物账户'], + ['slug' => 'vipshop', 'title' => '唯品会账户'], + ], + ], [ 'slug' => 'marketing-account-bills', 'title' => '店铺营销账户账单', diff --git a/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php b/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php index 3f7d7392..8858a2f1 100644 --- a/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php +++ b/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php @@ -147,6 +147,86 @@ class PinduoduoSpreadBillRepository ]; } + /** @return array{rows: array>, total: int} */ + public function paginateMonthlyBalances( + ?string $storeName = null, + ?string $month = null, + int $page = 1, + int $pageSize = 25 + ): array { + $page = max(1, $page); + $pageSize = max(1, min(5000, $pageSize)); + $monthExpression = 'DATEADD(MONTH, DATEDIFF(MONTH, 0, createTime), 0)'; + [$whereSql, $params] = $this->buildMonthlyWhere($storeName, $month); + + $total = (int) $this->query->scalar( + 'SELECT COUNT(*) FROM (' + . 'SELECT shopName, ' . $monthExpression . ' AS month_start ' + . 'FROM ' . self::TABLE . ' ' . $whereSql . ' ' + . 'GROUP BY shopName, ' . $monthExpression + . ') AS monthly_count', + $params + ); + + if ($total === 0) { + return ['rows' => [], 'total' => 0]; + } + + $offsetStart = (($page - 1) * $pageSize) + 1; + $offsetEnd = $page * $pageSize; + $sql = 'WITH monthly AS (' + . 'SELECT shopName, ' . $monthExpression . ' AS month_start, ' + . 'COALESCE(SUM(CASE WHEN flowType = 1 THEN amount ELSE 0 END), 0) AS income_total, ' + . 'COALESCE(SUM(CASE WHEN flowType = 2 THEN amount ELSE 0 END), 0) AS expense_total ' + . 'FROM ' . self::TABLE . ' ' . $whereSql . ' ' + . 'GROUP BY shopName, ' . $monthExpression + . '), numbered AS (' + . 'SELECT m.shopName, m.month_start, m.income_total, m.expense_total, ' + . 'opening_entry.balance AS opening_balance, closing_entry.balance AS system_closing_balance, ' + . 'ROW_NUMBER() OVER (ORDER BY m.month_start DESC, m.shopName ASC) AS rn ' + . 'FROM monthly m ' + . 'OUTER APPLY (SELECT TOP 1 p.balance FROM ' . self::TABLE . ' p ' + . 'WHERE p.shopName = m.shopName AND p.createTime < m.month_start ' + . 'ORDER BY p.createTime DESC, p.id DESC) opening_entry ' + . 'OUTER APPLY (SELECT TOP 1 p.balance FROM ' . self::TABLE . ' p ' + . 'WHERE p.shopName = m.shopName AND p.createTime < DATEADD(MONTH, 1, m.month_start) ' + . 'ORDER BY p.createTime DESC, p.id DESC) closing_entry' + . ') SELECT shopName, CONVERT(varchar(10), EOMONTH(month_start), 120) AS balance_date, ' + . 'opening_balance, income_total, expense_total, system_closing_balance, rn ' + . 'FROM numbered WHERE rn BETWEEN ? AND ? ORDER BY rn'; + $rows = $this->query->select($sql, array_merge($params, [$offsetStart, $offsetEnd])); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalizeMonthlyBalance($row), $rows), + 'total' => $total, + ]; + } + + /** @return array{0:string,1:array} */ + private function buildMonthlyWhere(?string $storeName, ?string $month): array + { + $conditions = [ + 'shopName IS NOT NULL', + 'createTime IS NOT NULL', + 'createTime < DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()), 0)', + ]; + $params = []; + + if ($storeName !== null && $storeName !== '') { + $conditions[] = 'shopName = ?'; + $params[] = $storeName; + } + + if ($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1) { + $monthStart = $month . '-01'; + $conditions[] = 'createTime >= ? AND createTime < DATEADD(MONTH, 1, CAST(? AS date))'; + $params[] = $monthStart; + $params[] = $monthStart; + } + + return ['WHERE ' . implode(' AND ', $conditions), $params]; + } + /** * 构造 WHERE 子句(不带前导空格时给前面加空格,避免拼到 FROM 后粘连)。 * @@ -210,4 +290,45 @@ class PinduoduoSpreadBillRepository 'fund_type_raw' => $fundType, ]; } + + /** @return array */ + private function normalizeMonthlyBalance(array $row): array + { + $opening = isset($row['opening_balance']) ? round((float) $row['opening_balance'], 2) : null; + $income = round((float) ($row['income_total'] ?? 0), 2); + $expense = round((float) ($row['expense_total'] ?? 0), 2); + $systemClosing = isset($row['system_closing_balance']) + ? round((float) $row['system_closing_balance'], 2) + : null; + $calculatedClosing = $opening === null ? null : round($opening + $income - $expense, 2); + $difference = $systemClosing === null || $calculatedClosing === null + ? null + : round($systemClosing - $calculatedClosing, 2); + + if ($opening === null && $systemClosing === null) { + $reason = '缺少期初余额和系统期末余额'; + } elseif ($opening === null) { + $reason = '缺少上月期末余额'; + } elseif ($systemClosing === null) { + $reason = '缺少本月系统期末余额'; + } elseif (abs($difference) < 0.005) { + $reason = ''; + } else { + $reason = '系统期末余额与计算期末余额相差 ' . number_format($difference, 2) . ' 元'; + } + + return [ + 'date' => (string) ($row['balance_date'] ?? ''), + 'store_name' => (string) ($row['shopName'] ?? ''), + 'opening_balance' => $opening, + 'income_total' => $income, + 'expense_total' => $expense, + 'closing_balance' => $calculatedClosing, + 'system_closing_balance' => $systemClosing, + 'balance_difference' => $difference, + 'difference_reason' => $reason, + 'verification_status' => $difference !== null && abs($difference) < 0.005 ? '已核对' : '待核对', + 'notes' => '', + ]; + } } diff --git a/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php b/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php index 0cd5761b..ff41da31 100644 --- a/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php +++ b/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php @@ -167,6 +167,86 @@ class TmallTaobaoSpreadBillRepository ]; } + /** @return array{rows: array>, total: int} */ + public function paginateMonthlyBalances( + ?string $storeName = null, + ?string $month = null, + int $page = 1, + int $pageSize = 25 + ): array { + $page = max(1, $page); + $pageSize = max(1, min(5000, $pageSize)); + $monthExpression = 'DATEADD(MONTH, DATEDIFF(MONTH, 0, transTime), 0)'; + [$where, $params] = $this->buildMonthlyWhere($storeName, $month); + + $total = (int) $this->query->scalar( + 'SELECT COUNT(*) FROM (' + . 'SELECT shopName, ' . $monthExpression . ' AS month_start ' + . 'FROM ' . self::TABLE . ' ' . $where . ' ' + . 'GROUP BY shopName, ' . $monthExpression + . ') AS monthly_count', + $params + ); + + if ($total === 0) { + return ['rows' => [], 'total' => 0]; + } + + $offsetStart = (($page - 1) * $pageSize) + 1; + $offsetEnd = $page * $pageSize; + $sql = 'WITH monthly AS (' + . 'SELECT shopName, ' . $monthExpression . ' AS month_start, ' + . 'COALESCE(SUM(CASE WHEN finType = \'DEBIT\' THEN amount ELSE 0 END), 0) AS income_total_raw, ' + . 'COALESCE(SUM(CASE WHEN finType = \'CREDIT\' THEN amount ELSE 0 END), 0) AS expense_total_raw ' + . 'FROM ' . self::TABLE . ' ' . $where . ' ' + . 'GROUP BY shopName, ' . $monthExpression + . '), numbered AS (' + . 'SELECT m.shopName, m.month_start, m.income_total_raw, m.expense_total_raw, ' + . 'opening_entry.balance AS opening_balance_raw, closing_entry.balance AS system_closing_balance_raw, ' + . 'ROW_NUMBER() OVER (ORDER BY m.month_start DESC, m.shopName ASC) AS rn ' + . 'FROM monthly m ' + . 'OUTER APPLY (SELECT TOP 1 t.balance FROM ' . self::TABLE . ' t ' + . 'WHERE t.shopName = m.shopName AND t.transTime < m.month_start ' + . 'ORDER BY t.transTime DESC, t.id DESC) opening_entry ' + . 'OUTER APPLY (SELECT TOP 1 t.balance FROM ' . self::TABLE . ' t ' + . 'WHERE t.shopName = m.shopName AND t.transTime < DATEADD(MONTH, 1, m.month_start) ' + . 'ORDER BY t.transTime DESC, t.id DESC) closing_entry' + . ') SELECT shopName, CONVERT(varchar(10), EOMONTH(month_start), 120) AS balance_date, ' + . 'opening_balance_raw, income_total_raw, expense_total_raw, system_closing_balance_raw, rn ' + . 'FROM numbered WHERE rn BETWEEN ? AND ? ORDER BY rn'; + $rows = $this->query->select($sql, array_merge($params, [$offsetStart, $offsetEnd])); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalizeMonthlyBalance($row), $rows), + 'total' => $total, + ]; + } + + /** @return array{0:string,1:array} */ + private function buildMonthlyWhere(?string $storeName, ?string $month): array + { + $conditions = [ + 'shopName IS NOT NULL', + 'transTime IS NOT NULL', + 'transTime < DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()), 0)', + ]; + $params = []; + + if ($storeName !== null && $storeName !== '') { + $conditions[] = 'shopName = ?'; + $params[] = $storeName; + } + + if ($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1) { + $monthStart = $month . '-01'; + $conditions[] = 'transTime >= ? AND transTime < DATEADD(MONTH, 1, CAST(? AS date))'; + $params[] = $monthStart; + $params[] = $monthStart; + } + + return ['WHERE ' . implode(' AND ', $conditions), $params]; + } + /** * 构造 WHERE 子句。 * @@ -241,4 +321,47 @@ class TmallTaobaoSpreadBillRepository 'trade_type_raw' => $tradeType, ]; } + + /** @return array */ + private function normalizeMonthlyBalance(array $row): array + { + $opening = isset($row['opening_balance_raw']) + ? round(((float) $row['opening_balance_raw']) / 100, 2) + : null; + $income = round(((float) ($row['income_total_raw'] ?? 0)) / 100, 2); + $expense = round(((float) ($row['expense_total_raw'] ?? 0)) / 100, 2); + $systemClosing = isset($row['system_closing_balance_raw']) + ? round(((float) $row['system_closing_balance_raw']) / 100, 2) + : null; + $calculatedClosing = $opening === null ? null : round($opening + $income - $expense, 2); + $difference = $systemClosing === null || $calculatedClosing === null + ? null + : round($systemClosing - $calculatedClosing, 2); + + if ($opening === null && $systemClosing === null) { + $reason = '缺少期初余额和系统期末余额'; + } elseif ($opening === null) { + $reason = '缺少上月期末余额'; + } elseif ($systemClosing === null) { + $reason = '缺少本月系统期末余额'; + } elseif (abs($difference) < 0.005) { + $reason = ''; + } else { + $reason = '系统期末余额与计算期末余额相差 ' . number_format($difference, 2) . ' 元'; + } + + return [ + 'date' => (string) ($row['balance_date'] ?? ''), + 'store_name' => (string) ($row['shopName'] ?? ''), + 'opening_balance' => $opening, + 'income_total' => $income, + 'expense_total' => $expense, + 'closing_balance' => $calculatedClosing, + 'system_closing_balance' => $systemClosing, + 'balance_difference' => $difference, + 'difference_reason' => $reason, + 'verification_status' => $difference !== null && abs($difference) < 0.005 ? '已核对' : '待核对', + 'notes' => '', + ]; + } } diff --git a/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php b/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php index 3a9f8a28..f24e43ca 100644 --- a/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php +++ b/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php @@ -58,7 +58,8 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher { $filters = (array) ($parameters['filters'] ?? []); $page = max((int) ($parameters['page'] ?? 1), 1); - $limit = min(max((int) ($parameters['limit'] ?? 25), 1), 100); + $maxLimit = ! empty($parameters['export']) ? 5000 : 100; + $limit = min(max((int) ($parameters['limit'] ?? 25), 1), $maxLimit); $storeName = isset($filters['shop_name']) ? (string) $filters['shop_name'] : (isset($filters['store_name']) ? (string) $filters['store_name'] : null); @@ -67,6 +68,7 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher $flowType = isset($filters['flow_type']) ? (string) $filters['flow_type'] : null; $flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : null; $productType = isset($filters['product_type']) ? (string) $filters['product_type'] : null; + $month = isset($filters['month']) ? (string) $filters['month'] : null; return match ($scope) { 'edge-banding.prices' => $this->edgeBanding->prices($filters, $page, $limit), @@ -98,14 +100,16 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher 'raw-board.prices' => $this->rawBoardPayables->prices($filters, $page, $limit), 'raw-board.reconciliations' => $this->rawBoardPayables->reconciliations($filters, $page, $limit), - 'pinduoduo-bills.list' => $this->pinduoduo->paginate( - $storeName, - $fromDate, - $toDate, - $flowType, - $page, - $limit - ), + 'pinduoduo-bills.list' => ($parameters['view'] ?? null) === 'monthly-balances' + ? $this->pinduoduo->paginateMonthlyBalances($storeName, $month, $page, $limit) + : $this->pinduoduo->paginate( + $storeName, + $fromDate, + $toDate, + $flowType, + $page, + $limit + ), 'pinduoduo-bills.summary' => $this->pinduoduo->summary( $storeName, $fromDate, @@ -140,14 +144,16 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher ), 'jd-wallet-bills.options' => $this->jdWallet->shopOptions(), - 'taobao-alipay-bills.list' => $this->taobaoAlipay->paginate( - $storeName, - $fromDate, - $toDate, - $flowType, - $page, - $limit - ), + 'taobao-alipay-bills.list' => ($parameters['view'] ?? null) === 'monthly-balances' + ? $this->taobaoAlipay->paginateMonthlyBalances($storeName, $month, $page, $limit) + : $this->taobaoAlipay->paginate( + $storeName, + $fromDate, + $toDate, + $flowType, + $page, + $limit + ), 'taobao-alipay-bills.summary' => $this->taobaoAlipay->summary( $storeName, $fromDate, diff --git a/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php b/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php new file mode 100644 index 00000000..ad0f28a4 --- /dev/null +++ b/resources/views/banking/marketing_account_balances/partials/embedded-table.blade.php @@ -0,0 +1,106 @@ +@php + $columns = [ + '日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额', + '系统期末余额', '余额差异', '差异原因', '核对状态', '备注', + ]; + $balances = $balances ?? collect(); + $supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'pinduoduo'], true); +@endphp + + + +
+
+ + {{ $activeContentTitle ?? '店铺营销账户余额' }} · 数据列表 +
+
+ +@if ($supportsBalances) + @php + $filters = $filters ?? ['store_name' => '', 'month' => '']; + $storeOptions = is_array($storeOptions ?? null) ? $storeOptions : []; + $currentStore = (string) ($filters['store_name'] ?? ''); + $currentMonth = (string) ($filters['month'] ?? ''); + @endphp +
+
+ + +
+
+ + +
+ + + 清除 + + + 导出 + +
+@endif + +@if (! empty($error)) + +@endif + +@if ($supportsBalances) +
+ + @foreach ($columns as $column)@endforeach + + @forelse ($balances as $row) + + + + @foreach (['opening_balance', 'income_total', 'expense_total', 'closing_balance', 'system_closing_balance', 'balance_difference'] as $field) + + @endforeach + + + + + @empty + + @endforelse + +
{{ $column }}
{{ $row['date'] }}{{ $row['store_name'] }}{{ $row[$field] === null ? '-' : number_format((float) $row[$field], 2) }}{{ $row['difference_reason'] }}{{ $row['verification_status'] }}{{ $row['notes'] }}
当前筛选条件下暂无已完整结束月份的余额数据。
+
+ @if ($balances instanceof \Illuminate\Contracts\Pagination\Paginator && $balances->hasPages()) +
{{ $balances->onEachSide(1)->links() }}
+ @endif +@else +
+ +
该平台余额数据表尚未配置。
+
+@endif diff --git a/resources/views/business/pages/show.blade.php b/resources/views/business/pages/show.blade.php index 782790e6..fb9f8657 100644 --- a/resources/views/business/pages/show.blade.php +++ b/resources/views/business/pages/show.blade.php @@ -1,6 +1,8 @@ @php $isMarketingAccountBillsPage = ($section['slug'] ?? null) === 'billing-transactions' && ($page['slug'] ?? null) === 'marketing-account-bills'; + $isMarketingAccountBalancePage = ($section['slug'] ?? null) === 'billing-transactions' + && ($page['slug'] ?? null) === 'marketing-account-balance'; $marketingAccountTabTitles = [ 'taobao-wechat' => 'Alibaba', 'jd-wallet' => '京东', @@ -394,6 +396,7 @@ @class([ 'my-6', 'business-page--marketing-account-bills' => $isMarketingAccountBillsPage, + 'business-page--marketing-account-balance' => $isMarketingAccountBalancePage, ]) @if ($isMarketingAccountBillsPage) data-marketing-account-bills-workspace @endif > diff --git a/routes/admin.php b/routes/admin.php index 2969744c..d4b316fc 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -61,6 +61,9 @@ Route::prefix('workspace-data/supplier-payables') * @see \modules\OfflinePayments\Routes\admin.php for module example */ +Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export') + ->name('business.pages.export'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/MarketingAccountBillsTabsTest.php b/tests/Feature/Business/MarketingAccountBillsTabsTest.php index be388a82..8e46a15e 100644 --- a/tests/Feature/Business/MarketingAccountBillsTabsTest.php +++ b/tests/Feature/Business/MarketingAccountBillsTabsTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; use Mockery; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; +use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCache; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; @@ -12,6 +13,48 @@ use Tests\Feature\FeatureTestCase; class MarketingAccountBillsTabsTest extends FeatureTestCase { + public function testMarketingAccountBalanceRedirectsToTaobaoAndShowsRestoredControls(): void + { + $this->allowMarketingAccountBalancePage(); + $repository = Mockery::mock(PinduoduoSpreadBillRepository::class); + $this->app->instance(PinduoduoSpreadBillRepository::class, $repository); + $cache = Mockery::mock(UpstreamQueryCache::class); + $cache->shouldReceive('remember')->twice()->andReturnUsing( + fn (string $scope): array => str_ends_with($scope, '.options') ? [] : ['rows' => [], 'total' => 0] + ); + $factory = Mockery::mock(UpstreamQueryCacheFactory::class); + $factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache); + $this->app->instance(UpstreamQueryCacheFactory::class, $factory); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'billing-transactions', + 'page' => 'marketing-account-balance', + ])) + ->assertRedirect(route('business.pages.show', [ + 'section' => 'billing-transactions', + 'page' => 'marketing-account-balance', + 'tab' => 'taobao-alipay', + ])); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'billing-transactions', + 'page' => 'marketing-account-balance', + 'tab' => 'pinduoduo', + ])) + ->assertOk() + ->assertSeeText('淘系账户') + ->assertSeeText('拼多多账户') + ->assertSeeText('店铺名称') + ->assertSeeText('期初余额') + ->assertSeeText('系统期末余额') + ->assertSeeText('查询') + ->assertSeeText('清除') + ->assertSeeText('导出') + ->assertDontSeeText('该页面暂未配置数据表'); + } + public function testMarketingAccountBillsOnlyRenamesTheRequestedTabs(): void { $this->allowMarketingAccountBillsPage(); @@ -130,4 +173,18 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase ], ], JSON_UNESCAPED_UNICODE)); } + + private function allowMarketingAccountBalancePage(): void + { + $this->user->update(['locale' => 'zh-CN']); + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $subject = 'user_' . $this->user->id; + + setting()->set('system.sidebar_permissions', json_encode([ + 'configured' => [$subject], + 'subjects' => [ + $subject => ['billing-transactions/marketing-account-balance'], + ], + ], JSON_UNESCAPED_UNICODE)); + } } diff --git a/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php b/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php index 23cfebaa..0a63d927 100644 --- a/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php +++ b/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php @@ -111,6 +111,33 @@ class JianSunSnapshotRefresherTest extends TestCase ); } + public function test_pinduoduo_monthly_balance_view_is_preserved_during_background_refresh(): void + { + $pinduoduo = new class extends PinduoduoSpreadBillRepository { + public array $arguments = []; + public function __construct() {} + public function paginateMonthlyBalances( + ?string $storeName = null, ?string $month = null, int $page = 1, int $pageSize = 25 + ): array { + $this->arguments = func_get_args(); + return ['rows' => [], 'total' => 0]; + } + }; + + $status = $this->refresher(pinduoduo: $pinduoduo)->refresh([ + 'scope' => 'pinduoduo-bills.list', + 'parameters' => [ + 'view' => 'monthly-balances', + 'filters' => ['store_name' => '测试店铺', 'month' => '2026-06'], + 'page' => 2, + 'limit' => 50, + ], + ]); + + $this->assertSame('fresh', $status); + $this->assertSame(['测试店铺', '2026-06', 2, 50], $pinduoduo->arguments); + } + public function test_taobao_alipay_filters_are_preserved_during_background_refresh(): void { $taobaoAlipay = new class extends TmallTaobaoSpreadBillRepository { @@ -156,6 +183,33 @@ class JianSunSnapshotRefresherTest extends TestCase ); } + public function test_taobao_monthly_balance_view_is_preserved_during_background_refresh(): void + { + $taobao = new class extends TmallTaobaoSpreadBillRepository { + public array $arguments = []; + public function __construct() {} + public function paginateMonthlyBalances( + ?string $storeName = null, ?string $month = null, int $page = 1, int $pageSize = 25 + ): array { + $this->arguments = func_get_args(); + return ['rows' => [], 'total' => 0]; + } + }; + + $status = $this->refresher(taobaoAlipay: $taobao)->refresh([ + 'scope' => 'taobao-alipay-bills.list', + 'parameters' => [ + 'view' => 'monthly-balances', + 'filters' => ['store_name' => '淘系店铺', 'month' => '2026-06'], + 'page' => 3, + 'limit' => 25, + ], + ]); + + $this->assertSame('fresh', $status); + $this->assertSame(['淘系店铺', '2026-06', 3, 25], $taobao->arguments); + } + public function test_inventory_scope_is_available_to_background_refresh(): void { $inventory = new class extends InventoryRepository { diff --git a/tests/Unit/EcommerceFinance/PinduoduoSpreadBillRepositoryTest.php b/tests/Unit/EcommerceFinance/PinduoduoSpreadBillRepositoryTest.php index 5a8dd66c..5bfa9272 100644 --- a/tests/Unit/EcommerceFinance/PinduoduoSpreadBillRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/PinduoduoSpreadBillRepositoryTest.php @@ -74,4 +74,49 @@ class PinduoduoSpreadBillRepositoryTest extends TestCase $this->assertStringContainsString('flowType = 1', $query->calls[1]['sql']); $this->assertStringContainsString('flowType = 2', $query->calls[2]['sql']); } + + public function test_monthly_balances_follow_the_reconciliation_formula(): void + { + $query = new class('jiansun') extends UpstreamReadOnlyQuery { + public array $calls = []; + + public function scalar(string $sql, array $bindings = []): mixed + { + $this->calls[] = compact('sql', 'bindings') + ['method' => 'scalar']; + + return 1; + } + + public function select(string $sql, array $bindings = []): array + { + $this->calls[] = compact('sql', 'bindings') + ['method' => 'select']; + + return [[ + 'shopName' => '拼多多_测试店铺', + 'balance_date' => '2026-06-30', + 'opening_balance' => 1000, + 'income_total' => 500, + 'expense_total' => 200, + 'system_closing_balance' => 1300, + ]]; + } + }; + $factory = new class($query) extends UpstreamReadOnlyQueryFactory { + public function __construct(private readonly UpstreamReadOnlyQuery $query) {} + public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; } + }; + + $result = (new PinduoduoSpreadBillRepository($factory))->paginateMonthlyBalances( + '拼多多_测试店铺', '2026-06', 2, 25, + ); + $row = $result['rows'][0]; + + $this->assertSame(1300.0, $row['closing_balance']); + $this->assertSame(0.0, $row['balance_difference']); + $this->assertSame('已核对', $row['verification_status']); + $this->assertStringContainsString('flowType = 1', $query->calls[1]['sql']); + $this->assertStringContainsString('flowType = 2', $query->calls[1]['sql']); + $this->assertStringContainsString('p.createTime < m.month_start', $query->calls[1]['sql']); + $this->assertSame(['拼多多_测试店铺', '2026-06-01', '2026-06-01', 26, 50], $query->calls[1]['bindings']); + } } diff --git a/tests/Unit/EcommerceFinance/TmallTaobaoSpreadBillRepositoryTest.php b/tests/Unit/EcommerceFinance/TmallTaobaoSpreadBillRepositoryTest.php index 1cbaaabb..0a017a44 100644 --- a/tests/Unit/EcommerceFinance/TmallTaobaoSpreadBillRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/TmallTaobaoSpreadBillRepositoryTest.php @@ -9,6 +9,53 @@ use PHPUnit\Framework\TestCase; class TmallTaobaoSpreadBillRepositoryTest extends TestCase { + public function test_monthly_balances_convert_cents_and_reconcile_by_store_and_month(): void + { + $query = new class('jiansun') extends UpstreamReadOnlyQuery { + public array $calls = []; + + public function scalar(string $sql, array $bindings = []): mixed + { + $this->calls[] = compact('sql', 'bindings') + ['method' => 'scalar']; + + return 1; + } + + public function select(string $sql, array $bindings = []): array + { + $this->calls[] = compact('sql', 'bindings') + ['method' => 'select']; + + return [[ + 'shopName' => '淘系_测试店铺', + 'balance_date' => '2026-06-30', + 'opening_balance_raw' => 100000, + 'income_total_raw' => 50000, + 'expense_total_raw' => 20000, + 'system_closing_balance_raw' => 130000, + ]]; + } + }; + $factory = new class($query) extends UpstreamReadOnlyQueryFactory { + public function __construct(private readonly UpstreamReadOnlyQuery $query) {} + public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; } + }; + + $result = (new TmallTaobaoSpreadBillRepository($factory))->paginateMonthlyBalances( + '淘系_测试店铺', '2026-06', 2, 25, + ); + $row = $result['rows'][0]; + + $this->assertSame(1000.0, $row['opening_balance']); + $this->assertSame(500.0, $row['income_total']); + $this->assertSame(200.0, $row['expense_total']); + $this->assertSame(1300.0, $row['closing_balance']); + $this->assertSame(0.0, $row['balance_difference']); + $this->assertSame('已核对', $row['verification_status']); + $this->assertStringContainsString("finType = 'DEBIT'", $query->calls[1]['sql']); + $this->assertStringContainsString("finType = 'CREDIT'", $query->calls[1]['sql']); + $this->assertSame(['淘系_测试店铺', '2026-06-01', '2026-06-01', 26, 50], $query->calls[1]['bindings']); + } + public function test_filters_and_cent_amounts_are_normalized_consistently(): void { $query = new class('jiansun') extends UpstreamReadOnlyQuery { -- 2.47.2