恢复店铺营销账户余额页面 #40
@ -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 = [
|
||||
|
||||
@ -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' => '店铺营销账户账单',
|
||||
|
||||
@ -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' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
>
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user