恢复店铺营销账户余额页面 #40

Merged
chenxi merged 2 commits from fix/chenxi-restore-marketing-account-balance into main 2026-08-13 11:41:42 +08:00
12 changed files with 722 additions and 18 deletions

View File

@ -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 = [

View File

@ -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' => '店铺营销账户账单',

View File

@ -147,6 +147,86 @@ class PinduoduoSpreadBillRepository
];
}
/** @return array{rows: array<int,array<string,mixed>>, 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<int,mixed>} */
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<string,mixed> */
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' => '',
];
}
}

View File

@ -167,6 +167,86 @@ class TmallTaobaoSpreadBillRepository
];
}
/** @return array{rows: array<int,array<string,mixed>>, 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<int,mixed>} */
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<string,mixed> */
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' => '',
];
}
}

View File

@ -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,

View File

@ -0,0 +1,106 @@
@php
$columns = [
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
];
$balances = $balances ?? collect();
$supportsBalances = in_array(($platform ?? null), ['taobao-alipay', 'pinduoduo'], true);
@endphp
<style>
.marketing-balance-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 16px; }
.marketing-balance-field { display: grid; gap: 4px; }
.marketing-balance-field label { color: #595959; font-size: 0.75rem; font-weight: 500; }
.marketing-balance-control { height: 34px; min-width: 180px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; padding: 0 10px; color: #171717; font-size: 0.8125rem; }
.marketing-balance-control:focus { border-color: var(--vip-content-accent-dark, #856632); outline: none; box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.20); }
.marketing-balance-button { display: inline-flex; height: 34px; align-items: center; gap: 6px; border-radius: 8px; padding: 0 14px; font-size: 0.8125rem; font-weight: 500; text-decoration: none; }
.marketing-balance-button-primary { border: 1px solid var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; }
.marketing-balance-button-primary:hover { background: #6A5129; color: #fff; }
.marketing-balance-button-secondary { border: 1px solid var(--vip-border, #D8D2C7); background: #fff; color: #171717; }
.marketing-balance-button-secondary:hover { background: var(--vip-content-accent-soft, #F7F0DE); color: #171717; }
.marketing-balance-pagination ul.pagination { display: flex; flex-flow: row wrap; justify-content: center; gap: 4px; margin: 16px 0 0; padding: 0; list-style: none; }
.marketing-balance-pagination .page-link { display: inline-flex; min-width: 34px; height: 34px; align-items: center; justify-content: center; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; padding: 0 10px; color: #171717; font-size: 0.8125rem; text-decoration: none; }
.marketing-balance-pagination .page-item.active .page-link { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; }
.marketing-balance-pagination .page-item.disabled .page-link { color: #595959; cursor: not-allowed; }
.marketing-balance-pagination div.lg\:flex > div:first-child { display: none !important; }
.marketing-balance-pagination div.lg\:flex { justify-content: center; }
</style>
<div class="biz-card-head">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">account_balance_wallet</span>
<span>{{ $activeContentTitle ?? '店铺营销账户余额' }} · 数据列表</span>
</div>
</div>
@if ($supportsBalances)
@php
$filters = $filters ?? ['store_name' => '', 'month' => ''];
$storeOptions = is_array($storeOptions ?? null) ? $storeOptions : [];
$currentStore = (string) ($filters['store_name'] ?? '');
$currentMonth = (string) ($filters['month'] ?? '');
@endphp
<form method="GET" action="{{ request()->url() }}" class="marketing-balance-filters">
<div class="marketing-balance-field">
<label for="marketing-balance-store">店铺名称</label>
<select id="marketing-balance-store" name="store_name" class="marketing-balance-control">
<option value="">全部店铺</option>
@foreach ($storeOptions as $option)
<option value="{{ $option }}" @selected($currentStore === (string) $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="marketing-balance-field">
<label for="marketing-balance-month">日期</label>
<input id="marketing-balance-month" type="month" name="month" value="{{ $currentMonth }}" class="marketing-balance-control">
</div>
<button type="submit" class="marketing-balance-button marketing-balance-button-primary">
<span class="material-icons-outlined text-base" aria-hidden="true">search</span>查询
</button>
<a href="{{ request()->url() }}" class="marketing-balance-button marketing-balance-button-secondary">
<span class="material-icons-outlined text-base" aria-hidden="true">clear</span>清除
</a>
<a href="{{ route('business.pages.export', [
'section' => $section['slug'], 'page' => $page['slug'], 'tab' => $platform,
'store_name' => $currentStore, 'month' => $currentMonth,
]) }}" class="marketing-balance-button marketing-balance-button-secondary">
<span class="material-icons-outlined text-base" aria-hidden="true">file_download</span>导出
</a>
</form>
@endif
@if (! empty($error))
<div class="biz-alert biz-alert-error" role="alert">{{ $error }}</div>
@endif
@if ($supportsBalances)
<div class="biz-table-wrap">
<table class="biz-table">
<thead><tr>@foreach ($columns as $column)<th>{{ $column }}</th>@endforeach</tr></thead>
<tbody>
@forelse ($balances as $row)
<tr>
<td>{{ $row['date'] }}</td>
<td>{{ $row['store_name'] }}</td>
@foreach (['opening_balance', 'income_total', 'expense_total', 'closing_balance', 'system_closing_balance', 'balance_difference'] as $field)
<td class="text-right tabular-nums">{{ $row[$field] === null ? '-' : number_format((float) $row[$field], 2) }}</td>
@endforeach
<td>{{ $row['difference_reason'] }}</td>
<td>{{ $row['verification_status'] }}</td>
<td>{{ $row['notes'] }}</td>
</tr>
@empty
<tr><td colspan="{{ count($columns) }}" class="biz-empty">当前筛选条件下暂无已完整结束月份的余额数据。</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($balances instanceof \Illuminate\Contracts\Pagination\Paginator && $balances->hasPages())
<div class="marketing-balance-pagination">{{ $balances->onEachSide(1)->links() }}</div>
@endif
@else
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">description</span>
<div>该平台余额数据表尚未配置。</div>
</div>
@endif

View File

@ -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
>

View File

@ -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');

View File

@ -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));
}
}

View File

@ -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 {

View File

@ -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']);
}
}

View File

@ -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 {