feat: 推广费营销账户明细汇总
新增推广费(营销账户)页签、平台与原始店铺汇总、备注筛选和明细弹窗。
This commit is contained in:
parent
26c86df915
commit
3be1639fb2
@ -554,6 +554,7 @@ $navigation = [
|
||||
['slug' => 'dewu', 'title' => '得物账户'],
|
||||
['slug' => 'vipshop', 'title' => '唯品会MP账户'],
|
||||
['slug' => 'vipshop-jx', 'title' => '唯品会JX账户'],
|
||||
['slug' => 'promotion-marketing', 'title' => '推广费(营销账户)'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -14,7 +14,9 @@ use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\PromotionMarketingExpenseRepository;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class AccountBills extends Controller
|
||||
{
|
||||
@ -40,6 +42,10 @@ class AccountBills extends Controller
|
||||
|
||||
$billType = $bill_type;
|
||||
|
||||
if ($billType === 'promotion-marketing') {
|
||||
return $this->promotionMarketingIndex($request);
|
||||
}
|
||||
|
||||
if (! AccountBillColumns::has($billType)) {
|
||||
abort(404, '未知的账户类型。');
|
||||
}
|
||||
@ -109,6 +115,136 @@ class AccountBills extends Controller
|
||||
return response()->json($this->withLedgerRemarks($data, $billType));
|
||||
}
|
||||
|
||||
/**
|
||||
* 推广费(营销账户)页签的固定响应结构。
|
||||
*
|
||||
* 各平台取数口径尚未配置,先返回明确的空结果与店铺明细列定义;后续接入时
|
||||
* 只替换这里的数据来源,不改页签、卡片和弹窗契约。
|
||||
*/
|
||||
private function promotionMarketingIndex(Request $request)
|
||||
{
|
||||
$page = max(1, (int) $request->get('page', 1));
|
||||
$limit = min(max((int) $request->get('limit', 10), 1), 100);
|
||||
|
||||
try {
|
||||
$result = app(PromotionMarketingExpenseRepository::class)->paginate(
|
||||
$this->promotionMarketingFilters($request),
|
||||
$page,
|
||||
$limit,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['rows'],
|
||||
'total' => $result['total'],
|
||||
'current_page' => $result['current_page'],
|
||||
'last_page' => $result['last_page'],
|
||||
'per_page' => $result['per_page'],
|
||||
'summary' => [
|
||||
'income' => 0,
|
||||
'expense' => $result['summary']['expense'],
|
||||
'balance' => round(-$result['summary']['expense'], 2),
|
||||
'count' => $result['summary']['count'],
|
||||
],
|
||||
'columns' => [
|
||||
'title' => '推广费(营销账户)',
|
||||
'groups' => [],
|
||||
'columns' => self::promotionExpenseDetailColumns(),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json([
|
||||
'message' => '推广费(营销账户)数据暂时无法读取,请稍后重试。',
|
||||
], 503);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推广费(营销账户)的店铺明细。当前先提供统一空结果,待逐平台取数口径
|
||||
* 确定后由专用 Repository 填充;筛选参数与主账单页保持一致。
|
||||
*/
|
||||
public function promotionExpenseDetails(Request $request, string $bill_type)
|
||||
{
|
||||
abort_unless($bill_type === 'promotion-marketing', 404);
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
try {
|
||||
$result = app(PromotionMarketingExpenseRepository::class)->storeSummary(
|
||||
$this->promotionMarketingFilters($request)
|
||||
);
|
||||
$storeCount = count($result['store_summary']);
|
||||
|
||||
return response()->json([
|
||||
'store_summary' => $result['store_summary'],
|
||||
'total' => $storeCount,
|
||||
'summary' => [
|
||||
'expense' => $result['total'],
|
||||
'count' => $storeCount,
|
||||
],
|
||||
'platform_summary' => $result['platform_summary'],
|
||||
'message' => $storeCount === 0 ? '当前筛选范围内暂无推广费支出明细。' : '',
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json([
|
||||
'message' => '店铺明细暂时无法读取,请稍后重试。',
|
||||
], 503);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{start:string,end:string,store_name:string,note:string} */
|
||||
private function promotionMarketingFilters(Request $request): array
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||||
'note' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'start' => trim((string) ($filters['start'] ?? '')),
|
||||
'end' => trim((string) ($filters['end'] ?? '')),
|
||||
'store_name' => trim((string) ($filters['store_name'] ?? '')),
|
||||
'note' => trim((string) ($filters['note'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function promotionMarketingFilterOptions(Request $request)
|
||||
{
|
||||
try {
|
||||
return response()->json([
|
||||
'shops' => app(PromotionMarketingExpenseRepository::class)->storeOptions(
|
||||
$this->promotionMarketingFilters($request),
|
||||
),
|
||||
'bill_items' => [],
|
||||
'ledger_remarks' => [],
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json([
|
||||
'message' => '推广费店铺选项暂时无法读取。',
|
||||
'shops' => [],
|
||||
'bill_items' => [],
|
||||
'ledger_remarks' => [],
|
||||
], 503);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int,array{label:string,field:string,kind:string}> */
|
||||
private static function promotionExpenseDetailColumns(): array
|
||||
{
|
||||
return [
|
||||
['label' => '店铺名称', 'field' => 'store_name', 'kind' => 'text'],
|
||||
['label' => '交易时间', 'field' => 'occurred_at', 'kind' => 'date'],
|
||||
['label' => '备注', 'field' => 'notes', 'kind' => 'text'],
|
||||
['label' => '支出金额', 'field' => 'expense', 'kind' => 'money'],
|
||||
];
|
||||
}
|
||||
|
||||
public function export(Request $request, string $bill_type)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
@ -145,6 +281,10 @@ class AccountBills extends Controller
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
if ($bill_type === 'promotion-marketing') {
|
||||
return $this->promotionMarketingFilterOptions(request());
|
||||
}
|
||||
|
||||
$ledgerRemarks = $this->ledgerRemarkMap()->remarksForTab($bill_type);
|
||||
|
||||
if (in_array($bill_type, ['vipshop', 'vipshop-jx'], true)) {
|
||||
|
||||
@ -0,0 +1,491 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 七个营销账户平台的统一推广费支出明细。
|
||||
*
|
||||
* 所有平台先按交易日期做 COUNT/SUM 直方图确定全局分页窗口,再只读取当前页
|
||||
* 涉及的日期。这样不需要把单月十几万条拼多多流水一次性拉进网页进程。
|
||||
*/
|
||||
class PromotionMarketingExpenseRepository
|
||||
{
|
||||
private const CACHE_SECONDS = 120;
|
||||
|
||||
/** @var array<string,UpstreamReadOnlyQuery> */
|
||||
private array $queries = [];
|
||||
|
||||
public function __construct(private readonly UpstreamReadOnlyQueryFactory $factory)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* rows:array<int,array<string,mixed>>,
|
||||
* total:int,
|
||||
* current_page:int,
|
||||
* last_page:int,
|
||||
* per_page:int,
|
||||
* summary:array{expense:float,count:int}
|
||||
* }
|
||||
*/
|
||||
public function paginate(array $filters, int $page = 1, int $limit = 25): array
|
||||
{
|
||||
[$start, $end] = $this->dateRange($filters);
|
||||
$store = trim((string) ($filters['store_name'] ?? ''));
|
||||
$page = max(1, $page);
|
||||
$limit = min(max($limit, 1), 100);
|
||||
$cacheKey = 'promotion-marketing-expense:page:' . hash('sha256', json_encode([
|
||||
$start, $end, $store, $page, $limit,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_SECONDS, function () use ($start, $end, $store, $page, $limit): array {
|
||||
$histograms = [];
|
||||
$countByDate = [];
|
||||
$expenseByPlatform = [];
|
||||
$total = 0;
|
||||
$expenseTotal = 0.0;
|
||||
|
||||
foreach ($this->sources() as $key => $source) {
|
||||
$histograms[$key] = $this->histogram($source, $start, $end, $store);
|
||||
foreach ($histograms[$key] as $date => $values) {
|
||||
$countByDate[$date] = ($countByDate[$date] ?? 0) + $values['count'];
|
||||
$expenseByPlatform[$source['platform_category']] =
|
||||
($expenseByPlatform[$source['platform_category']] ?? 0.0) + $values['expense'];
|
||||
$total += $values['count'];
|
||||
$expenseTotal += $values['expense'];
|
||||
}
|
||||
}
|
||||
|
||||
krsort($countByDate, SORT_STRING);
|
||||
[$dates, $skip] = $this->pageWindow($countByDate, ($page - 1) * $limit, $limit);
|
||||
$rows = $dates === [] ? [] : $this->rowsForDates($histograms, $dates, $start, $end, $store);
|
||||
usort($rows, static function (array $left, array $right): int {
|
||||
foreach ([
|
||||
strcmp($right['report_date'], $left['report_date']),
|
||||
$left['source_order'] <=> $right['source_order'],
|
||||
strcmp($left['store_name'], $right['store_name']),
|
||||
strcmp($left['notes'], $right['notes']),
|
||||
$right['expense'] <=> $left['expense'],
|
||||
] as $comparison) {
|
||||
if ($comparison !== 0) {
|
||||
return $comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
$rows = array_slice($rows, $skip, $limit);
|
||||
foreach ($rows as $index => &$row) {
|
||||
unset($row['report_date'], $row['source'], $row['source_order']);
|
||||
$row['id'] = hash('sha256', $index . '|' . json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$platformSummary = [];
|
||||
foreach ($expenseByPlatform as $platform => $amount) {
|
||||
$platformSummary[] = ['platform' => $platform, 'amount' => round($amount, 2)];
|
||||
}
|
||||
usort($platformSummary, static fn (array $left, array $right): int =>
|
||||
[$right['amount'], $left['platform']] <=> [$left['amount'], $right['platform']]);
|
||||
|
||||
return [
|
||||
'rows' => array_values($rows),
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => max(1, (int) ceil($total / $limit)),
|
||||
'per_page' => $limit,
|
||||
'summary' => [
|
||||
'expense' => round($expenseTotal, 2),
|
||||
'count' => $total,
|
||||
],
|
||||
'platform_summary' => $platformSummary,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台 + 原始店铺的推广费汇总,供弹窗“平台汇总 / 店铺明细”使用。
|
||||
*
|
||||
* 弹窗内的平台和店铺筛选在已汇总的小结果集上执行,不再重复查询上游。
|
||||
*
|
||||
* @return array{
|
||||
* store_summary:array<int,array{platform:string,store_name:string,amount:float}>,
|
||||
* platform_summary:array<int,array{platform:string,amount:float}>,
|
||||
* total:float
|
||||
* }
|
||||
*/
|
||||
public function storeSummary(array $filters): array
|
||||
{
|
||||
[$start, $end] = $this->dateRange($filters);
|
||||
$note = trim((string) ($filters['note'] ?? ''));
|
||||
$cacheKey = 'promotion-marketing-expense:stores-summary:'
|
||||
. hash('sha256', $start . '|' . $end . '|' . $note);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_SECONDS, function () use ($start, $end, $note): array {
|
||||
$merged = [];
|
||||
foreach ($this->sources() as $source) {
|
||||
[$where, $bindings] = $this->where($source, $start, $end, '', false, $note);
|
||||
$sql = 'SELECT ' . $source['store'] . ' AS store_name,'
|
||||
. ' COALESCE(SUM(' . $source['expense'] . '), 0) AS amount'
|
||||
. ' FROM ' . $source['table'] . ' c ' . $where
|
||||
. ' AND ' . $source['store'] . " IS NOT NULL AND LTRIM(RTRIM(" . $source['store'] . ")) <> N''"
|
||||
. ' GROUP BY ' . $source['store']
|
||||
. ' HAVING ABS(SUM(' . $source['expense'] . ')) > 0.004';
|
||||
foreach ($this->select($source['connection'], $sql, $bindings) as $row) {
|
||||
$store = $source['store_label'] ?? trim((string) ($row['store_name'] ?? ''));
|
||||
if ($store === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $source['platform_category'] . '|' . $store;
|
||||
$merged[$key] = ($merged[$key] ?? 0.0) + (float) ($row['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$stores = [];
|
||||
$platforms = [];
|
||||
$total = 0.0;
|
||||
foreach ($merged as $key => $amount) {
|
||||
[$platform, $store] = explode('|', $key, 2);
|
||||
$amount = round($amount, 2);
|
||||
$stores[] = ['platform' => $platform, 'store_name' => $store, 'amount' => $amount];
|
||||
$platforms[$platform] = ($platforms[$platform] ?? 0.0) + $amount;
|
||||
$total += $amount;
|
||||
}
|
||||
usort($stores, static fn (array $left, array $right): int =>
|
||||
[$right['amount'], $left['platform'], $left['store_name']]
|
||||
<=> [$left['amount'], $right['platform'], $right['store_name']]);
|
||||
|
||||
$platformSummary = [];
|
||||
foreach ($platforms as $platform => $amount) {
|
||||
$platformSummary[] = ['platform' => $platform, 'amount' => round($amount, 2)];
|
||||
}
|
||||
usort($platformSummary, static fn (array $left, array $right): int =>
|
||||
[$right['amount'], $left['platform']] <=> [$left['amount'], $right['platform']]);
|
||||
|
||||
return [
|
||||
'store_summary' => $stores,
|
||||
'platform_summary' => $platformSummary,
|
||||
'total' => round($total, 2),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
public function storeOptions(array $filters = []): array
|
||||
{
|
||||
[$start, $end] = $this->dateRange($filters);
|
||||
$cacheKey = 'promotion-marketing-expense:stores:' . hash('sha256', $start . '|' . $end);
|
||||
|
||||
return Cache::remember($cacheKey, 3600, function () use ($start, $end): array {
|
||||
$stores = [];
|
||||
foreach ($this->sources() as $source) {
|
||||
[$where, $bindings] = $this->where($source, $start, $end, '');
|
||||
$sql = 'SELECT DISTINCT TOP (500) ' . $source['store'] . ' AS store_name'
|
||||
. ' FROM ' . $source['table'] . ' c ' . $where
|
||||
. ' AND ' . $source['store'] . " IS NOT NULL AND LTRIM(RTRIM(" . $source['store'] . ")) <> N''"
|
||||
. ' ORDER BY store_name';
|
||||
foreach ($this->select($source['connection'], $sql, $bindings) as $row) {
|
||||
$name = $source['store_label'] ?? trim((string) ($row['store_name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$stores[$name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$names = array_keys($stores);
|
||||
sort($names, SORT_NATURAL);
|
||||
|
||||
return array_slice($names, 0, 500);
|
||||
});
|
||||
}
|
||||
|
||||
private function histogram(array $source, string $start, string $end, string $store): array
|
||||
{
|
||||
[$where, $bindings] = $this->where($source, $start, $end, $store);
|
||||
$date = 'CONVERT(varchar(10), ' . $source['date'] . ', 23)';
|
||||
$sql = 'SELECT ' . $date . ' AS report_date, COUNT(*) AS row_count,'
|
||||
. ' COALESCE(SUM(' . $source['expense'] . '), 0) AS expense'
|
||||
. ' FROM ' . $source['table'] . ' c ' . $where
|
||||
. ' GROUP BY ' . $date;
|
||||
|
||||
$result = [];
|
||||
foreach ($this->select($source['connection'], $sql, $bindings) as $row) {
|
||||
$dateValue = substr(trim((string) ($row['report_date'] ?? '')), 0, 10);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateValue) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$result[$dateValue] = [
|
||||
'count' => max(0, (int) ($row['row_count'] ?? 0)),
|
||||
'expense' => round((float) ($row['expense'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,array{count:int,expense:float}>> $histograms
|
||||
* @param array<int,string> $dates
|
||||
*/
|
||||
private function rowsForDates(array $histograms, array $dates, string $start, string $end, string $store): array
|
||||
{
|
||||
$first = min($dates);
|
||||
$last = max($dates);
|
||||
$lastExclusive = CarbonImmutable::parse($last, 'Asia/Shanghai')->addDay()->toDateString();
|
||||
$rows = [];
|
||||
|
||||
foreach ($this->sources() as $key => $source) {
|
||||
if (! isset($histograms[$key]) || $histograms[$key] === []) {
|
||||
continue;
|
||||
}
|
||||
[$where, $bindings] = $this->where($source, $first, $lastExclusive, $store, true);
|
||||
$sql = 'SELECT ' . $source['row'] . ', '
|
||||
. 'CONVERT(varchar(10), ' . $source['date'] . ', 23) AS report_date, '
|
||||
. $source['store'] . ' AS store_name, '
|
||||
. $source['expense'] . ' AS expense'
|
||||
. ' FROM ' . $source['table'] . ' c ' . $where;
|
||||
foreach ($this->select($source['connection'], $sql, $bindings) as $row) {
|
||||
$reportDate = substr(trim((string) ($row['report_date'] ?? '')), 0, 10);
|
||||
if (! isset($histograms[$key][$reportDate])) {
|
||||
continue;
|
||||
}
|
||||
$normalized = $this->normalize($key, $source, $row, $reportDate);
|
||||
if ($normalized !== null) {
|
||||
$rows[] = $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,int> $countByDate
|
||||
* @return array{0:array<int,string>,1:int}
|
||||
*/
|
||||
private function pageWindow(array $countByDate, int $offset, int $limit): array
|
||||
{
|
||||
$dates = [];
|
||||
$skip = 0;
|
||||
$cumulative = 0;
|
||||
$target = $offset + $limit;
|
||||
|
||||
foreach ($countByDate as $date => $count) {
|
||||
$next = $cumulative + $count;
|
||||
if ($next <= $offset) {
|
||||
$cumulative = $next;
|
||||
continue;
|
||||
}
|
||||
if ($dates === []) {
|
||||
$skip = $offset - $cumulative;
|
||||
}
|
||||
$dates[] = $date;
|
||||
$cumulative = $next;
|
||||
if ($cumulative >= $target) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [$dates, max(0, $skip)];
|
||||
}
|
||||
|
||||
private function normalize(string $key, array $source, array $row, string $reportDate): ?array
|
||||
{
|
||||
$expense = round((float) ($row['expense'] ?? 0), 2);
|
||||
if ($expense <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$primary = trim((string) ($row['note_primary'] ?? ''));
|
||||
$secondary = trim((string) ($row['note_secondary'] ?? ''));
|
||||
if ($key === 'pinduoduo') {
|
||||
$secondary = ['1' => '现金', '3' => '红包/券'][$secondary] ?? '';
|
||||
}
|
||||
$notes = implode(' · ', array_values(array_filter(
|
||||
[$primary, $secondary],
|
||||
static fn (string $value): bool => $value !== '',
|
||||
)));
|
||||
|
||||
return [
|
||||
'source' => $key,
|
||||
'source_order' => $source['order'],
|
||||
'report_date' => $reportDate,
|
||||
'store_name' => $source['store_label'] ?? trim((string) ($row['store_name'] ?? '')),
|
||||
'occurred_at' => trim((string) ($row['occurred_at'] ?? '')),
|
||||
'notes' => $notes,
|
||||
'expense' => $expense,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:array<int,mixed>}
|
||||
*/
|
||||
private function where(
|
||||
array $source,
|
||||
string $start,
|
||||
string $end,
|
||||
string $store,
|
||||
bool $endExclusive = false,
|
||||
?string $note = null,
|
||||
): array {
|
||||
$conditions = [$source['expense_condition']];
|
||||
$bindings = [$start];
|
||||
$conditions[] = $source['date'] . ' >= CAST(? AS date)';
|
||||
|
||||
if ($endExclusive) {
|
||||
$conditions[] = $source['date'] . ' < CAST(? AS date)';
|
||||
$bindings[] = $end;
|
||||
} else {
|
||||
$conditions[] = $source['date'] . ' < DATEADD(day, 1, CAST(? AS date))';
|
||||
$bindings[] = $end;
|
||||
}
|
||||
|
||||
if ($store !== '') {
|
||||
if (isset($source['store_label'])) {
|
||||
if ($store !== $source['store_label']) {
|
||||
$conditions[] = '1 = 0';
|
||||
}
|
||||
} else {
|
||||
$conditions[] = $source['store'] . ' = ?';
|
||||
$bindings[] = $store;
|
||||
}
|
||||
}
|
||||
if ($note !== null && $note !== '') {
|
||||
$conditions[] = '(' . $source['note_filter'] . ") LIKE ? ESCAPE '!'";
|
||||
$bindings[] = $this->contains($note);
|
||||
}
|
||||
|
||||
return [' WHERE ' . implode(' AND ', $conditions), $bindings];
|
||||
}
|
||||
|
||||
private function contains(string $value): string
|
||||
{
|
||||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function dateRange(array $filters): array
|
||||
{
|
||||
$start = trim((string) ($filters['start'] ?? ''));
|
||||
$end = trim((string) ($filters['end'] ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) !== 1) {
|
||||
$start = now('Asia/Shanghai')->startOfMonth()->toDateString();
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $end) !== 1) {
|
||||
$end = now('Asia/Shanghai')->endOfMonth()->toDateString();
|
||||
}
|
||||
if ($start > $end) {
|
||||
throw new RuntimeException('推广费查询的结束日期不能早于开始日期。');
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> */
|
||||
private function sources(): array
|
||||
{
|
||||
return [
|
||||
'taobao' => [
|
||||
'order' => 1,
|
||||
'connection' => 'jiansun',
|
||||
'table' => '[dbo].[tmall_spread_bills]',
|
||||
'date' => 'c.[transTime]',
|
||||
'store' => 'c.[shopName]',
|
||||
'expense' => '(CAST(c.[amount] AS decimal(18,2)) / 100.0)',
|
||||
'expense_condition' => "c.[finType] = 'CREDIT' AND ISNULL(c.[amount], 0) <> 0",
|
||||
'note_filter' => 'c.[comment]',
|
||||
'platform_category' => '淘系',
|
||||
'row' => "CONVERT(varchar(10), c.[transTime], 23) AS occurred_at, c.[comment] AS note_primary, NULL AS note_secondary",
|
||||
],
|
||||
'alibaba' => [
|
||||
'order' => 2,
|
||||
'connection' => 'jiansun',
|
||||
'table' => '[dbo].[alibaba_bill]',
|
||||
'date' => 'CAST(c.[tradeDay] AS date)',
|
||||
'store' => 'c.[shopName]',
|
||||
'expense' => 'CAST(c.[tradeAMTOut] AS decimal(18,2))',
|
||||
'expense_condition' => 'ISNULL(c.[tradeAMTOut], 0) > 0',
|
||||
'note_filter' => 'c.[tradeTypeName]',
|
||||
'platform_category' => '淘系',
|
||||
'row' => "CONVERT(varchar(10), CAST(c.[tradeDay] AS date), 23) AS occurred_at, c.[tradeTypeName] AS note_primary, NULL AS note_secondary",
|
||||
],
|
||||
'pinduoduo' => [
|
||||
'order' => 3,
|
||||
'connection' => 'jiansun',
|
||||
'table' => '[dbo].[pdd_spread_bills]',
|
||||
'date' => 'c.[createTime]',
|
||||
'store' => 'c.[shopName]',
|
||||
'expense' => 'CAST(c.[amount] AS decimal(18,2))',
|
||||
'expense_condition' => 'c.[flowType] = 2 AND ISNULL(c.[amount], 0) <> 0',
|
||||
'note_filter' => "CONCAT(ISNULL(c.[summary], N''), N' ', CASE c.[toAdvertiserFundType] WHEN 1 THEN N'现金' WHEN 3 THEN N'红包/券' ELSE N'' END)",
|
||||
'platform_category' => '拼多多',
|
||||
'row' => "CONVERT(varchar(19), c.[createTime], 120) AS occurred_at, c.[summary] AS note_primary, CONVERT(varchar(10), c.[toAdvertiserFundType]) AS note_secondary",
|
||||
],
|
||||
'jd' => [
|
||||
'order' => 4,
|
||||
'connection' => 'js_data_middle_platform',
|
||||
'table' => '[JSDataMiddlePlatform].[dbo].[jd_jzt_finance_records]',
|
||||
'date' => 'CAST(c.[show_date] AS date)',
|
||||
'store' => 'c.[shop_name]',
|
||||
'expense' => 'CAST(c.[amount] AS decimal(18,2))',
|
||||
'expense_condition' => "c.[in_out_type] = 'OUT' AND ISNULL(c.[amount], 0) <> 0",
|
||||
'note_filter' => "CONCAT(ISNULL(c.[remark], N''), N' ', ISNULL(c.[trade_type_name], N''))",
|
||||
'platform_category' => '京东',
|
||||
'row' => "CONVERT(varchar(10), CAST(c.[show_date] AS date), 23) AS occurred_at, c.[remark] AS note_primary, c.[trade_type_name] AS note_secondary",
|
||||
],
|
||||
'douyin' => [
|
||||
'order' => 5,
|
||||
'connection' => 'js_data_middle_platform',
|
||||
'table' => '[JSDataMiddlePlatform].[dbo].[dy_qianchuan_finance_transactions]',
|
||||
'date' => 'c.[statDate]',
|
||||
'store' => 'c.[shopName]',
|
||||
'expense' => 'CAST(c.[cost] AS decimal(18,2))',
|
||||
'expense_condition' => "c.[rowType] = N'daily' AND c.[status] = N'success' AND ISNULL(c.[cost], 0) <> 0",
|
||||
'note_filter' => 'c.[remark]',
|
||||
'platform_category' => '抖音',
|
||||
'row' => "CONVERT(varchar(10), c.[statDate], 23) AS occurred_at, c.[remark] AS note_primary, NULL AS note_secondary",
|
||||
],
|
||||
'dewu' => [
|
||||
'order' => 6,
|
||||
'connection' => 'js_data_middle_platform',
|
||||
'table' => '[JSDataMiddlePlatform].[dbo].[dw_ad_finance_records]',
|
||||
'date' => 'c.[trade_date]',
|
||||
'store' => 'c.[shop_name]',
|
||||
'expense' => 'CAST(c.[trade_amount_yuan] AS decimal(18,2))',
|
||||
'expense_condition' => "c.[flow_type] = N'支出' AND ISNULL(c.[trade_amount_yuan], 0) <> 0",
|
||||
'note_filter' => "CONCAT(ISNULL(c.[remark], N''), N' ', ISNULL(c.[amount_type], N''))",
|
||||
'platform_category' => '得物',
|
||||
'row' => "CONVERT(varchar(10), c.[trade_date], 23) AS occurred_at, c.[remark] AS note_primary, c.[amount_type] AS note_secondary",
|
||||
],
|
||||
'vip' => [
|
||||
'order' => 7,
|
||||
'connection' => 'js_data_middle_platform',
|
||||
'table' => '[JSDataMiddlePlatform].[dbo].[vip_marketingDailyReport]',
|
||||
'date' => 'c.[reportDate]',
|
||||
'store' => 'c.[accountName]',
|
||||
'store_label' => '唯品会JITX_亿家达',
|
||||
'expense' => 'CAST(c.[costYuan] AS decimal(18,2))',
|
||||
'expense_condition' => 'ISNULL(c.[costYuan], 0) <> 0',
|
||||
'note_filter' => "N'推广费'",
|
||||
'platform_category' => '唯品会',
|
||||
'row' => "CONVERT(varchar(10), c.[reportDate], 23) AS occurred_at, N'推广费' AS note_primary, NULL AS note_secondary",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
protected function select(string $connection, string $sql, array $bindings): array
|
||||
{
|
||||
$query = $this->queries[$connection] ??= $this->factory->forSourceWithTimeout($connection, 45);
|
||||
|
||||
return $query->select($sql, $bindings);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,137 @@
|
||||
<template>
|
||||
<div class="table-workspace">
|
||||
<div class="tw-stats">
|
||||
<div v-for="stat in stats" :key="stat.label" class="tw-stat"><div class="tw-label">{{ stat.label }}</div><div class="tw-value" :class="stat.className">{{ stat.value }}</div><div class="tw-note">{{ stat.note }}</div></div>
|
||||
<div class="tw-stats" :class="{ 'tw-stats--promotion-only': isPromotionMarketing }">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="tw-stat"
|
||||
:class="{ 'tw-stat--promotion': stat.action }"
|
||||
>
|
||||
<div class="tw-label">{{ stat.label }}</div>
|
||||
<div class="tw-value" :class="stat.className">{{ stat.value }}</div>
|
||||
<div class="tw-note">{{ stat.note }}</div>
|
||||
<button
|
||||
v-if="stat.action"
|
||||
type="button"
|
||||
class="tw-promotion-link"
|
||||
aria-label="查看店铺明细"
|
||||
@click="openPromotionDetails"
|
||||
>查看明细 →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="promotionModalOpen"
|
||||
ref="promotionModal"
|
||||
class="tw-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="promotion-expense-detail-title"
|
||||
tabindex="-1"
|
||||
@click.self="closePromotionDetails"
|
||||
@keydown.esc="closePromotionDetails"
|
||||
>
|
||||
<div class="tw-modal__box tw-promotion-modal">
|
||||
<div class="tw-modal__head">
|
||||
<div>
|
||||
<strong id="promotion-expense-detail-title">推广费(营销账户) · 明细汇总</strong>
|
||||
<div class="tw-promotion-subtitle">按当前筛选范围</div>
|
||||
</div>
|
||||
<div class="tw-promotion-head-total">
|
||||
<span>当前筛选合计(万元)</span>
|
||||
<strong>{{ promotionWan(promotionFilteredExpenseTotal) }}</strong>
|
||||
</div>
|
||||
<button type="button" class="tw-modal__close" aria-label="关闭店铺明细" @click="closePromotionDetails">✕</button>
|
||||
</div>
|
||||
<div class="tw-modal__body">
|
||||
<div class="tw-promotion-filters">
|
||||
<label>
|
||||
<span>平台</span>
|
||||
<select v-model="promotionPlatformFilter" aria-label="筛选平台" @change="promotionStoreFilter = promotionStoreOptions.includes(promotionStoreFilter) ? promotionStoreFilter : ''">
|
||||
<option value="">全部平台</option>
|
||||
<option v-for="platform in promotionPlatformOptions" :key="platform" :value="platform">{{ platform }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>店铺名称</span>
|
||||
<select v-model="promotionStoreFilter" aria-label="筛选店铺名称">
|
||||
<option value="">全部店铺</option>
|
||||
<option v-for="store in promotionStoreOptions" :key="store" :value="store">{{ store }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>备注</span>
|
||||
<input v-model.trim="promotionNoteFilter" type="text" placeholder="备注关键词" aria-label="筛选备注" @input="schedulePromotionExpenseReload">
|
||||
</label>
|
||||
</div>
|
||||
<div class="tw-promotion-tabs" role="tablist" aria-label="推广费汇总子页面">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="promotionExpenseTab === 'platform' ? 'true' : 'false'"
|
||||
:class="{ 'is-active': promotionExpenseTab === 'platform' }"
|
||||
@click="promotionExpenseTab = 'platform'"
|
||||
>平台汇总</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="promotionExpenseTab === 'detail' ? 'true' : 'false'"
|
||||
:class="{ 'is-active': promotionExpenseTab === 'detail' }"
|
||||
@click="promotionExpenseTab = 'detail'"
|
||||
>店铺明细</button>
|
||||
</div>
|
||||
<div v-if="promotionExpenseError" class="tw-promotion-state is-error" role="alert">
|
||||
推广费明细加载失败,请稍后重试。
|
||||
<button type="button" class="tw-button" @click="fetchPromotionExpenseDetails()">重新加载</button>
|
||||
</div>
|
||||
<div v-else-if="promotionExpenseLoading" class="tw-promotion-state" role="status">加载中…</div>
|
||||
<div v-else-if="!promotionStoreSummary.length" class="tw-promotion-state" role="status">
|
||||
{{ promotionExpenseMessage || '当前筛选范围内暂无推广费明细。' }}
|
||||
</div>
|
||||
<div v-else-if="promotionExpenseTab === 'platform'" class="tw-promotion-table-wrap">
|
||||
<div>
|
||||
<table class="tw-table tw-promotion-platform-table">
|
||||
<thead>
|
||||
<tr><th scope="col">平台</th><th scope="col" class="is-number">推广费(万元)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in promotionFilteredPlatformRows" :key="item.platform">
|
||||
<td>{{ item.platform }}</td>
|
||||
<td class="is-number">{{ promotionWan(item.amount) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!promotionFilteredPlatformRows.length">
|
||||
<td colspan="2" class="tw-empty">当前筛选范围内暂无平台汇总</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="tw-promotion-table-wrap">
|
||||
<div>
|
||||
<table class="tw-table tw-promotion-platform-table">
|
||||
<thead>
|
||||
<tr><th scope="col">平台</th><th scope="col">店铺名称</th><th scope="col" class="is-number">推广费金额(万元)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in promotionFilteredStoreRows" :key="row.platform + '|' + row.store_name">
|
||||
<td>{{ row.platform }}</td>
|
||||
<td>{{ row.store_name }}</td>
|
||||
<td class="is-number">{{ promotionWan(row.amount) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!promotionFilteredStoreRows.length"><td colspan="3" class="tw-empty">当前筛选范围内暂无店铺汇总</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-modal__foot">
|
||||
<span class="tw-promotion-total">
|
||||
{{ promotionExpenseTab === 'platform'
|
||||
? `共 ${promotionFilteredPlatformRows.length} 条平台汇总`
|
||||
: `共 ${promotionFilteredStoreRows.length} 条店铺明细` }}
|
||||
</span>
|
||||
<button type="button" class="tw-button" @click="closePromotionDetails">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form class="tw-filter" @submit.prevent="applyFilters">
|
||||
<label>店铺
|
||||
@ -10,13 +140,13 @@
|
||||
</el-select>
|
||||
<input v-else v-model.trim="filters.store_name" placeholder="店铺名称" />
|
||||
</label>
|
||||
<label>总账备注
|
||||
<label v-if="!isPromotionMarketing">总账备注
|
||||
<el-select v-if="usesMirrorOptions && !filterOptionsError" v-model="filters.ledger_remark" filterable clearable :loading="filterOptionsLoading" placeholder="全部总账备注" aria-label="总账备注筛选" popper-class="account-bill-filter-options" no-match-text="没有匹配的总账备注" no-data-text="暂无总账备注" @change="applyFilters">
|
||||
<el-option v-for="remark in filterOptions.ledger_remarks" :key="remark" :label="remark" :value="remark" />
|
||||
</el-select>
|
||||
<input v-else v-model.trim="filters.ledger_remark" placeholder="总账备注" />
|
||||
</label>
|
||||
<label>账单项目
|
||||
<label v-if="!isPromotionMarketing">账单项目
|
||||
<el-select v-if="usesMirrorOptions && !filterOptionsError" v-model="filters.bill_item" filterable clearable :loading="filterOptionsLoading" placeholder="全部账单项目" aria-label="账单项目筛选" popper-class="account-bill-filter-options" no-match-text="没有匹配的账单项目" no-data-text="暂无账单项目" @change="applyFilters">
|
||||
<el-option v-for="item in filterOptions.bill_items" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
@ -27,18 +157,18 @@
|
||||
<div class="tw-date-row">
|
||||
<span class="tw-date-control has-picker" @click="usesStableDateControl && openDatePicker('startDate')">
|
||||
<flat-picker ref="startDate" :value="filters.start" :config="startDateConfig" aria-label="开始日期" placeholder="开始日期" @on-change="(_, value) => changeDate('start', value)" />
|
||||
<button v-if="filters.start" type="button" class="tw-date-clear" title="清除开始日期" aria-label="清除开始日期" @click.stop="changeDate('start', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
|
||||
<button type="button" class="tw-date-picker" title="选择开始日期" aria-label="选择开始日期" @click.stop="openDatePicker('startDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button>
|
||||
<button v-if="filters.start" type="button" class="tw-date-clear" title="清除开始日期" aria-label="清除开始日期" @click.stop="changeDate('start', '')">×</button>
|
||||
<button type="button" class="tw-date-picker" title="选择开始日期" aria-label="选择开始日期" @click.stop="openDatePicker('startDate')"><svg class="tw-date-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"></rect><path d="M16 3v4M8 3v4M3 10h18"></path></svg></button>
|
||||
</span>
|
||||
<b>至</b>
|
||||
<span class="tw-date-control has-picker" @click="usesStableDateControl && openDatePicker('endDate')">
|
||||
<flat-picker ref="endDate" :value="filters.end" :config="endDateConfig" aria-label="结束日期" placeholder="结束日期" @on-change="(_, value) => changeDate('end', value)" />
|
||||
<button v-if="filters.end" type="button" class="tw-date-clear" title="清除结束日期" aria-label="清除结束日期" @click.stop="changeDate('end', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
|
||||
<button type="button" class="tw-date-picker" title="选择结束日期" aria-label="选择结束日期" @click.stop="openDatePicker('endDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button>
|
||||
<button v-if="filters.end" type="button" class="tw-date-clear" title="清除结束日期" aria-label="清除结束日期" @click.stop="changeDate('end', '')">×</button>
|
||||
<button type="button" class="tw-date-picker" title="选择结束日期" aria-label="选择结束日期" @click.stop="openDatePicker('endDate')"><svg class="tw-date-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"></rect><path d="M16 3v4M8 3v4M3 10h18"></path></svg></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="tw-keyword">关键词(记录编码 / 订单 / 备注)<span class="tw-keyword-controls"><input v-model.trim="filters.keyword" placeholder="输入关键词" aria-label="关键词" /></span></label>
|
||||
<label v-if="!isPromotionMarketing" class="tw-keyword">关键词(记录编码 / 订单 / 备注)<span class="tw-keyword-controls"><input v-model.trim="filters.keyword" placeholder="输入关键词" aria-label="关键词" /></span></label>
|
||||
<div class="tw-actions"><button class="tw-button tw-primary" type="submit" :disabled="invalidDateRange">{{ loading ? '查询中…' : '查询' }}</button><button class="tw-button" type="button" @click="resetFilters">重置</button></div>
|
||||
<p v-if="invalidDateRange" class="tw-filter-error" role="alert">结束日期不能早于开始日期</p>
|
||||
<p v-if="filterOptionsError" class="tw-filter-error" role="alert">筛选选项加载失败,可输入名称查询。<button type="button" class="tw-button" @click="fetchFilterOptions">重试选项</button></p>
|
||||
@ -50,9 +180,11 @@
|
||||
<div class="tw-toolbar">
|
||||
<div><strong>{{ sourceGroups.length ? '账单明细' : columnsTitle }}</strong><span v-if="!loading" class="tw-count">{{ total }} 条</span></div>
|
||||
<div class="tw-tools" v-if="sourceColumns.length">
|
||||
<select v-model="exportMode" title="选择导出范围" aria-label="导出范围"><option value="page-visible">当前页({{ rows.length }} 条)· 当前显示列</option><option value="filtered-visible">全部筛选结果({{ total }} 条)· 当前显示列</option><option value="filtered-all">全部筛选结果({{ total }} 条)· 全部字段</option></select>
|
||||
<button type="button" class="tw-button" :disabled="!canExport || exporting" @click="exportRows"><span class="material-icons-outlined" aria-hidden="true">download</span>{{ exporting ? '提交中…' : (exportMode === 'page-visible' ? '导出当前页' : '导出全部筛选结果') }}</button>
|
||||
<button type="button" class="tw-button" @click="openExportModal"><span class="material-icons-outlined" aria-hidden="true">assignment</span>导出进度</button>
|
||||
<template v-if="!isPromotionMarketing">
|
||||
<select v-model="exportMode" title="选择导出范围" aria-label="导出范围"><option value="page-visible">当前页({{ rows.length }} 条)· 当前显示列</option><option value="filtered-visible">全部筛选结果({{ total }} 条)· 当前显示列</option><option value="filtered-all">全部筛选结果({{ total }} 条)· 全部字段</option></select>
|
||||
<button type="button" class="tw-button" :disabled="!canExport || exporting" @click="exportRows"><span class="material-icons-outlined" aria-hidden="true">download</span>{{ exporting ? '提交中…' : (exportMode === 'page-visible' ? '导出当前页' : '导出全部筛选结果') }}</button>
|
||||
<button type="button" class="tw-button" @click="openExportModal"><span class="material-icons-outlined" aria-hidden="true">assignment</span>导出进度</button>
|
||||
</template>
|
||||
<table-column-settings :columns="sourceColumns" :user-key="userKey" page-key="store-receiving-bills" :tab-key="billType" table-key="bill-details" @change="configuredColumns = $event" />
|
||||
</div>
|
||||
</div>
|
||||
@ -126,16 +258,18 @@ const SORTABLE = ['occurred_at','income','expense','balance','record_code'];
|
||||
const JD_BILL_TABS = ['jd-transactions','jingxi'];
|
||||
// 时间筛选标签与该页签的时间列名保持一致(其余页签沿用「发生时间」)。
|
||||
// 唯品会两个页签的筛选项按「账单期间」筛(后端也只按 payload.settle_period 过滤)。
|
||||
const DATE_FILTER_LABELS = {'jd-transactions':'费用结算时间','jingxi':'费用结算时间','jd-wallet':'费用结算时间','douyin':'结算时间','vipshop':'账单期间','vipshop-jx':'账单期间'};
|
||||
const DATE_FILTER_LABELS = {'jd-transactions':'费用结算时间','jingxi':'费用结算时间','jd-wallet':'费用结算时间','douyin':'结算时间','vipshop':'账单期间','vipshop-jx':'账单期间','promotion-marketing':'交易时间'};
|
||||
const dateConfig = {dateFormat:'Y-m-d',locale:Mandarin,disableMobile:true,allowInput:false,onReady:(_,value,picker)=>picker.calendarContainer.classList.add('account-bill-calendar')};
|
||||
// 默认账期=本月:不填日期时查全表(几百万行)会很慢,默认落到当月既快又符合日常用法。
|
||||
const currentMonthRange = () => { const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const pad = value => String(value).padStart(2, '0'); const lastDay = new Date(year, month, 0).getDate(); return { start: `${year}-${pad(month)}-01`, end: `${year}-${pad(month)}-${pad(lastDay)}` }; };
|
||||
const emptyFilters = billType => ({store_name:'',ledger_remark:'',bill_item:'',...currentMonthRange(),keyword:'',keyword_match:'contains'});
|
||||
export default {
|
||||
name:'AccountBills', components:{ TableColumnSettings, flatPicker, [Select.name]:Select, [Option.name]:Option }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true}, progressBase:{type:String,default:''}, userKey:{type:[String,Number],required:true} },
|
||||
data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[],ledger_remarks:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible',exporting:false,exportModalOpen:false,exportTasks:[],exportHint:'导出在后台生成,成功后点「下载」;期间可以继续查看其它页面。',exportTimer:null};},
|
||||
data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[],ledger_remarks:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,promotionRequestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible',exporting:false,exportModalOpen:false,exportTasks:[],exportHint:'导出在后台生成,成功后点「下载」;期间可以继续查看其它页面。',exportTimer:null,promotionModalOpen:false,promotionExpenseTab:'platform',promotionExpenseLoading:false,promotionExpenseError:false,promotionStoreSummary:[],promotionExpenseSummary:{expense:0,count:0},promotionExpenseMessage:'',promotionPlatformFilter:'',promotionStoreFilter:'',promotionNoteFilter:'',promotionNoteTimer:null};},
|
||||
data(){return{loading:false,loadError:false,sync:null,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:emptyFilters(this.billType),queryFilters:emptyFilters(this.billType),resultFilters:null,filterOptions:{shops:[],bill_items:[],ledger_remarks:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,promotionRequestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible',exporting:false,exportModalOpen:false,exportTasks:[],exportHint:'导出在后台生成,成功后点「下载」;期间可以继续查看其它页面。',exportTimer:null,promotionModalOpen:false,promotionExpenseTab:'platform',promotionExpenseLoading:false,promotionExpenseError:false,promotionStoreSummary:[],promotionExpenseSummary:{expense:0,count:0},promotionExpenseMessage:'',promotionPlatformFilter:'',promotionStoreFilter:''};},
|
||||
computed:{
|
||||
isDouyin(){return this.billType==='douyin';},
|
||||
isPromotionMarketing(){return this.billType==='promotion-marketing';},
|
||||
companyPrefix(){const matched=(this.apiBase||'').match(/^\/\d+/);return matched?matched[0]:'';},
|
||||
tasksBaseUrl(){return this.companyPrefix+'/ecommerce-finance/account-bills/export-tasks';},
|
||||
tasksUrl(){return this.tasksBaseUrl+'/data';},
|
||||
@ -145,7 +279,7 @@ export default {
|
||||
// Every account-bill tab uses the same explicit picker path. Keeping one
|
||||
// control avoids browser-native date inputs appearing on older tabs.
|
||||
usesStableDateControl(){return true;},
|
||||
usesMirrorOptions(){return ['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jingxi','jd-wallet','vipshop','vipshop-jx'].indexOf(this.billType)!==-1;},
|
||||
usesMirrorOptions(){return ['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jingxi','jd-wallet','vipshop','vipshop-jx','promotion-marketing'].indexOf(this.billType)!==-1;},
|
||||
invalidDateRange(){return Boolean(this.filters.start&&this.filters.end&&this.filters.start>this.filters.end);},
|
||||
startDateConfig(){return {...dateConfig,maxDate:this.filters.end||null};},
|
||||
endDateConfig(){return {...dateConfig,minDate:this.filters.start||null};},
|
||||
@ -153,16 +287,72 @@ export default {
|
||||
canExport(){return !this.loading&&!this.loadError&&!this.invalidDateRange&&!this.filtersDirty&&this.total>0&&(this.exportMode==='filtered-all'||this.visibleColumns.length>0);},
|
||||
visibleColumns(){return this.configuredColumns.filter(column=>column.visible);},
|
||||
visibleGroups(){return this.visibleColumns.reduce((groups,column)=>{if(!column.group)return groups;const last=groups[groups.length-1];if(last&&last.label===column.group)last.span+=1;else groups.push({label:column.group,span:1});return groups;},[]);},
|
||||
stats(){const netLabel=JD_BILL_TABS.concat(['vipshop-jx']).indexOf(this.billType)!==-1;return[{label:'收入合计',value:this.moneyWan(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},{label:'支出合计',value:this.moneyWan(this.summary.expense),note:'按当前筛选',className:'is-expense'},{label:netLabel?'收支净额':(this.billType==='jd-wallet'?'账户余额合计(最新)':'期末余额(最新)'),value:this.moneyWan(this.summary.balance),note:netLabel?'按当前筛选':(this.billType==='jd-wallet'?'筛选结果中每个店铺账户最新一笔':'最新一条记录'),className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];},
|
||||
stats(){
|
||||
if(this.isPromotionMarketing){
|
||||
return [{label:'推广费(营销账户)',value:this.moneyWan(this.promotionExpenseSummary.expense),note:`${this.promotionExpenseSummary.count} 笔支出 · 按当前筛选`,className:'is-expense',action:true}];
|
||||
}
|
||||
const netLabel=JD_BILL_TABS.concat(['vipshop-jx']).indexOf(this.billType)!==-1;
|
||||
const items=[
|
||||
{label:'收入合计',value:this.moneyWan(this.summary.income),note:`${this.summary.count} 笔交易`,className:'is-income'},
|
||||
{label:'支出合计',value:this.moneyWan(this.summary.expense),note:'按当前筛选',className:'is-expense'},
|
||||
{label:netLabel?'收支净额':(this.billType==='jd-wallet'?'账户余额合计(最新)':'期末余额(最新)'),value:this.moneyWan(this.summary.balance),note:netLabel?'按当前筛选':(this.billType==='jd-wallet'?'筛选结果中每个店铺账户最新一笔':'最新一条记录'),className:''},
|
||||
{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''},
|
||||
];
|
||||
return items;
|
||||
},
|
||||
promotionPlatformOptions(){return [...new Set(this.promotionStoreSummary.map(row=>row.platform))];},
|
||||
promotionStoreOptions(){return [...new Set(this.promotionStoreSummary.filter(row=>!this.promotionPlatformFilter||row.platform===this.promotionPlatformFilter).map(row=>row.store_name))];},
|
||||
promotionFilteredStoreRows(){return this.promotionStoreSummary.filter(row=>(!this.promotionPlatformFilter||row.platform===this.promotionPlatformFilter)&&(!this.promotionStoreFilter||row.store_name===this.promotionStoreFilter));},
|
||||
promotionFilteredPlatformRows(){const totals={};this.promotionFilteredStoreRows.forEach(row=>{totals[row.platform]=(totals[row.platform]||0)+Number(row.amount||0);});return Object.keys(totals).map(platform=>({platform,amount:totals[platform]})).sort((left,right)=>right.amount-left.amount||left.platform.localeCompare(right.platform,'zh-CN'));},
|
||||
promotionFilteredExpenseTotal(){return this.promotionFilteredStoreRows.reduce((sum,row)=>sum+Number(row.amount||0),0);},
|
||||
rangeStart(){return this.total===0?0:(this.currentPage-1)*this.perPage+1;},rangeEnd(){return Math.min(this.currentPage*this.perPage,this.total);}
|
||||
},
|
||||
mounted(){if(this.usesMirrorOptions)this.fetchFilterOptions();this.fetch();},
|
||||
beforeDestroy(){if(this.promotionNoteTimer)window.clearTimeout(this.promotionNoteTimer);},
|
||||
methods:{
|
||||
async fetchFilterOptions(){this.filterOptionsLoading=true;this.filterOptionsError=false;try{const response=await window.axios.get(`${this.apiBase}/filter-options`);const body=response.data||{};this.filterOptions={shops:Array.isArray(body.shops)?body.shops:[],bill_items:Array.isArray(body.bill_items)?body.bill_items:[],ledger_remarks:Array.isArray(body.ledger_remarks)?body.ledger_remarks:[]};}catch(error){this.filterOptionsError=true;}finally{this.filterOptionsLoading=false;}},
|
||||
async fetch(){const requestId=++this.requestSerial;const requestedFilters={...this.queryFilters};this.loading=true;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...requestedFilters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});if(requestId!==this.requestSerial)return;const body=response.data||{};this.resultFilters=requestedFilters;this.sync=body.sync||null;this.rows=body.data||[];this.total=body.total||0;this.currentPage=body.current_page||1;this.lastPage=body.last_page||1;this.perPage=body.per_page||this.perPage;this.summary=body.summary||{income:0,expense:0,balance:0,count:0};if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){if(requestId!==this.requestSerial)return;this.rows=[];this.total=0;this.sync=null;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{if(requestId===this.requestSerial)this.loading=false;}},
|
||||
async fetchFilterOptions(){this.filterOptionsLoading=true;this.filterOptionsError=false;try{const response=await window.axios.get(`${this.apiBase}/filter-options`,{params:{start:this.filters.start,end:this.filters.end}});const body=response.data||{};this.filterOptions={shops:Array.isArray(body.shops)?body.shops:[],bill_items:Array.isArray(body.bill_items)?body.bill_items:[],ledger_remarks:Array.isArray(body.ledger_remarks)?body.ledger_remarks:[]};}catch(error){this.filterOptionsError=true;}finally{this.filterOptionsLoading=false;}},
|
||||
async fetch(){const requestId=++this.requestSerial;const requestedFilters={...this.queryFilters};this.loading=true;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...requestedFilters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});if(requestId!==this.requestSerial)return;const body=response.data||{};this.resultFilters=requestedFilters;this.sync=body.sync||null;this.rows=body.data||[];this.total=body.total||0;this.currentPage=body.current_page||1;this.lastPage=body.last_page||1;this.perPage=body.per_page||this.perPage;this.summary=body.summary||{income:0,expense:0,balance:0,count:0};if(this.isPromotionMarketing){this.promotionExpenseSummary={expense:Number(this.summary.expense||0),count:Number(this.summary.count||0)};}if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){if(requestId!==this.requestSerial)return;this.rows=[];this.total=0;this.sync=null;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{if(requestId===this.requestSerial)this.loading=false;}},
|
||||
attachGroups(columns,groups){let index=0;return columns.map((column,columnIndex)=>{while(groups[index]&&columnIndex>=groups.slice(0,index+1).reduce((sum,group)=>sum+Number(group.span||0),0))index+=1;return{...column,key:column.field||`column-${columnIndex}`,group:groups[index]?groups[index].label:''};});},
|
||||
applyFilters(){if(this.invalidDateRange)return;this.queryFilters={...this.filters};this.currentPage=1;return this.fetch();},resetFilters(){this.filters=emptyFilters(this.billType);return this.applyFilters();},changePage(page){this.currentPage=page;this.fetch();this.$nextTick(()=>{if(this.$refs.scroll)this.$refs.scroll.scrollTop=0;});},
|
||||
openDatePicker(refName){const picker=this.$refs[refName];if(picker&&picker.fp)picker.fp.open();},
|
||||
async fetchPromotionExpenseDetails(filters=null){
|
||||
const requestId=++this.promotionRequestSerial;
|
||||
const requestedFilters={...(filters||this.resultFilters||this.queryFilters)};
|
||||
this.promotionExpenseLoading=true;
|
||||
this.promotionExpenseError=false;
|
||||
this.promotionExpenseMessage='';
|
||||
try{
|
||||
const response=await window.axios.get(`${this.apiBase}/promotion-expense-details`,{params:{...requestedFilters,note:this.promotionNoteFilter},headers:{Accept:'application/json'}});
|
||||
if(requestId!==this.promotionRequestSerial)return;
|
||||
const body=response.data||{};
|
||||
this.promotionStoreSummary=Array.isArray(body.store_summary)?body.store_summary:[];
|
||||
this.promotionExpenseSummary={
|
||||
expense:Number((body.summary||{}).expense||0),
|
||||
count:Number((body.summary||{}).count||0),
|
||||
};
|
||||
this.promotionExpenseMessage=body.message||'';
|
||||
}catch(error){
|
||||
if(requestId!==this.promotionRequestSerial)return;
|
||||
this.promotionStoreSummary=[];
|
||||
this.promotionExpenseSummary={expense:0,count:0};
|
||||
this.promotionExpenseError=true;
|
||||
}finally{
|
||||
if(requestId===this.promotionRequestSerial)this.promotionExpenseLoading=false;
|
||||
}
|
||||
},
|
||||
openPromotionDetails(){
|
||||
this.promotionModalOpen=true;
|
||||
this.promotionExpenseTab='platform';
|
||||
this.promotionPlatformFilter='';
|
||||
this.promotionStoreFilter='';
|
||||
this.promotionNoteFilter='';
|
||||
if(this.promotionNoteTimer){window.clearTimeout(this.promotionNoteTimer);this.promotionNoteTimer=null;}
|
||||
this.fetchPromotionExpenseDetails();
|
||||
this.$nextTick(()=>{if(this.$refs.promotionModal)this.$refs.promotionModal.focus();});
|
||||
},
|
||||
closePromotionDetails(){this.promotionModalOpen=false;},
|
||||
schedulePromotionExpenseReload(){if(this.promotionNoteTimer)window.clearTimeout(this.promotionNoteTimer);this.promotionNoteTimer=window.setTimeout(()=>{this.promotionNoteTimer=null;this.promotionPlatformFilter='';this.promotionStoreFilter='';this.fetchPromotionExpenseDetails();},350);},
|
||||
promotionWan(value){return`¥${(Number(value||0)/10000).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}`;},
|
||||
// 选日期不自动查询:日期是一次选两个(开始/结束),中途触发会先按半截条件查全表,很慢。改完点「查询」再生效。
|
||||
changeDate(field,value){const date=value||'';if(this.filters[field]===date)return;this.filters[field]=date;},
|
||||
maybeSort(column){if(!this.sortableField(column.field))return;this.sort.dir=this.sort.key===column.field&&this.sort.dir==='desc'?'asc':'desc';this.sort.key=column.field;this.applyFilters();},sortableField(field){return SORTABLE.indexOf(field)!==-1;},sortMark(field){return this.sort.key===field?(this.sort.dir==='asc'?'↑':'↓'):'↕';},
|
||||
@ -332,3 +522,48 @@ export default {
|
||||
.account-bill-filter-options{max-width:calc(100vw - 32px)}
|
||||
.account-bill-filter-options .el-select-dropdown__item{max-width:100%;text-overflow:ellipsis;overflow:hidden}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.tw-stats--promotion-only{grid-template-columns:minmax(0,1fr)}
|
||||
.tw-stat--promotion{display:flex;min-height:5.25rem;flex-direction:column}
|
||||
.tw-promotion-link{margin-top:auto;align-self:flex-end;padding:0;border:0;background:transparent;color:#856632;font:500 .75rem/1.5 inherit;text-decoration:underline;text-underline-offset:2px;cursor:pointer}
|
||||
.tw-promotion-link:hover{color:#6a5129}
|
||||
.tw-promotion-link:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(133,102,50,.2);border-radius:.25rem}
|
||||
.tw-promotion-modal{width:min(980px,100%)}
|
||||
.tw-promotion-modal .tw-modal__head{gap:1rem}
|
||||
.tw-promotion-subtitle{margin-top:.2rem;color:#595959;font-size:.6875rem;font-weight:400}
|
||||
.tw-promotion-head-total{margin-left:auto;text-align:right;font-size:.6875rem;font-weight:400;color:#595959}
|
||||
.tw-promotion-head-total strong{display:block;margin-top:.15rem;color:#171717;font-size:.875rem;font-variant-numeric:tabular-nums}
|
||||
.tw-promotion-filters{display:grid;grid-template-columns:minmax(14rem,1fr) minmax(16rem,1.4fr) minmax(14rem,1fr);gap:.75rem;margin-bottom:.8rem;padding:.7rem .8rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fbfaf7}
|
||||
.tw-promotion-filters label{display:flex;min-width:0;flex-direction:column;gap:.25rem;color:#595959;font-size:.6875rem;font-weight:500}
|
||||
.tw-promotion-filters input,.tw-promotion-filters select{height:2rem;min-width:0;padding:0 .55rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:400 .8125rem/1 inherit}
|
||||
.tw-promotion-filters input:focus,.tw-promotion-filters select:focus{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2);outline:none}
|
||||
.tw-promotion-tabs{display:flex;gap:1.25rem;margin-bottom:.85rem;border-bottom:1px solid #d8d2c7}
|
||||
.tw-promotion-tabs button{position:relative;padding:.55rem 0;border:0;background:transparent;color:#595959;font:500 .8125rem/1.4 inherit;cursor:pointer}
|
||||
.tw-promotion-tabs button.is-active{color:#856632}
|
||||
.tw-promotion-tabs button.is-active:after{content:"";position:absolute;right:0;bottom:-1px;left:0;height:3px;background:#b9954f}
|
||||
.tw-promotion-tabs button:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(133,102,50,.2);border-radius:.25rem}
|
||||
.tw-promotion-state{display:flex;min-height:8rem;align-items:center;justify-content:center;gap:.75rem;border:1px dashed #d8d2c7;border-radius:.5rem;background:#fbfaf7;color:#595959;text-align:center}
|
||||
.tw-promotion-state.is-error{border-color:#b80000;background:#fff5f5;color:#b80000}
|
||||
.tw-promotion-table-wrap{display:flex;max-height:min(58vh,34rem);flex-direction:column;overflow:hidden;border:1px solid #d8d2c7;border-radius:.5rem}
|
||||
.tw-promotion-table-wrap>div:first-child{overflow:auto}
|
||||
.tw-promotion-table{min-width:100%}
|
||||
.tw-promotion-table th{top:0}
|
||||
.tw-promotion-table td:nth-child(3){max-width:26rem;white-space:normal}
|
||||
.tw-promotion-platform-table{width:100%;min-width:32rem}
|
||||
.tw-promotion-platform-table th:first-child,.tw-promotion-platform-table td:first-child{text-align:left}
|
||||
.tw-promotion-pager{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.5rem .75rem;border-top:1px solid #d8d2c7;background:#fff;color:#595959;font-size:.75rem}
|
||||
.tw-promotion-pager>div{display:flex;gap:.5rem}
|
||||
.tw-promotion-pager .tw-button{height:1.75rem}
|
||||
.tw-promotion-total{margin-right:auto;color:#595959;font-size:.75rem}
|
||||
.tw-date-icon{width:1rem;height:1rem;fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8}
|
||||
.tw-date-control.has-picker input{border-radius:999px 0 0 999px}
|
||||
.tw-date.is-stable-date .tw-date-control{height:2rem;border-radius:999px}
|
||||
.tw-date.is-stable-date .tw-date-picker,.tw-date.is-stable-date .tw-date-clear{height:1.875rem}
|
||||
.tw-date.is-stable-date .tw-date-picker{border-radius:0 999px 999px 0}
|
||||
.tw-date.is-stable-date .tw-date-clear{right:2.125rem;background:transparent;color:#171717;font-size:1.1rem;border-left:0}
|
||||
.tw-date.is-stable-date .tw-date-control.has-picker input{padding:0 3.75rem 0 .9rem}
|
||||
.tw-filter>.tw-date{flex:0 1 28rem;min-width:27rem}
|
||||
@media(max-width:900px){.tw-promotion-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.tw-promotion-filters label:last-child{grid-column:1/-1}}
|
||||
@media(max-width:520px){.tw-stats--promotion-only{grid-template-columns:1fr}.tw-modal__foot{align-items:stretch;flex-direction:column}.tw-promotion-total{margin-right:0;text-align:left}.tw-promotion-pager{align-items:stretch;flex-direction:column}.tw-promotion-pager>div{justify-content:flex-end}.tw-promotion-filters{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
6
resources/assets/js/views/business/pages.js
vendored
6
resources/assets/js/views/business/pages.js
vendored
@ -5,7 +5,7 @@
|
||||
*
|
||||
* 店铺收款账户账单(store-receiving-bills)下各 tab:
|
||||
* - taobao-alipay → 独立实现 TaobaoAlipayBills(经营分析副本共享镜像)
|
||||
* - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / jingxi / dewu / vipshop / vipshop-jx
|
||||
* - taobao-wechat / pinduoduo / jd-wallet / jd-transactions / jingxi / dewu / vipshop / vipshop-jx / promotion-marketing
|
||||
* → 通用实现 AccountBills(表 ec_account_bills,按 bill_type 区分)
|
||||
* - douyin → AccountBills 展示共享原始账单镜像中的头条放心购渠道
|
||||
*/
|
||||
@ -16,8 +16,8 @@ import Vue from 'vue';
|
||||
import TaobaoAlipayBills from './../../components/business/TaobaoAlipayBills.vue';
|
||||
import AccountBills from './../../components/business/AccountBills.vue';
|
||||
|
||||
const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx'];
|
||||
const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx'];
|
||||
const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx', 'promotion-marketing'];
|
||||
const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx', 'promotion-marketing'];
|
||||
|
||||
function pageContext(pathname = window.location.pathname) {
|
||||
const parts = (pathname || '').split('/').filter(Boolean);
|
||||
|
||||
@ -59,6 +59,8 @@ Route::get('ecommerce-finance/account-bills/type/taobao-wechat/filter-options',
|
||||
->name('ecommerce-finance.taobao-wechat-account-bills.filter-options');
|
||||
Route::get('ecommerce-finance/account-bills/type/taobao-wechat/export', [TaobaoWechatAccountBills::class, 'export'])
|
||||
->name('ecommerce-finance.taobao-wechat-account-bills.export');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}/promotion-expense-details', [AccountBills::class, 'promotionExpenseDetails'])
|
||||
->name('ecommerce-finance.account-bills.promotion-expense-details');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}', [AccountBills::class, 'index'])
|
||||
->name('ecommerce-finance.account-bills.index');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}/filter-options', [AccountBills::class, 'filterOptions'])
|
||||
|
||||
125
tests/Feature/Business/PromotionMarketingAccountBillsTest.php
Normal file
125
tests/Feature/Business/PromotionMarketingAccountBillsTest.php
Normal file
@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\PromotionMarketingExpenseRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PromotionMarketingAccountBillsTest extends FeatureTestCase
|
||||
{
|
||||
public function test_promotion_marketing_tab_is_next_to_vipshop_jx(): void
|
||||
{
|
||||
$pages = collect(config('business_navigation'))
|
||||
->firstWhere('slug', 'billing-transactions')['pages'];
|
||||
$tabs = collect($pages)->firstWhere('slug', 'store-receiving-bills')['tabs'];
|
||||
$slugs = array_column($tabs, 'slug');
|
||||
$vipshopJxIndex = array_search('vipshop-jx', $slugs, true);
|
||||
|
||||
$this->assertNotFalse($vipshopJxIndex);
|
||||
$this->assertSame('promotion-marketing', $slugs[$vipshopJxIndex + 1] ?? null);
|
||||
$this->assertSame('推广费(营销账户)', $tabs[$vipshopJxIndex + 1]['title'] ?? null);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'billing-transactions',
|
||||
'page' => 'store-receiving-bills',
|
||||
'tab' => 'promotion-marketing',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('推广费(营销账户)');
|
||||
}
|
||||
|
||||
public function test_promotion_marketing_page_and_details_share_contract(): void
|
||||
{
|
||||
$repository = Mockery::mock(PromotionMarketingExpenseRepository::class);
|
||||
$result = [
|
||||
'rows' => [[
|
||||
'id' => 'row-1',
|
||||
'store_name' => '测试推广店',
|
||||
'occurred_at' => '2026-10-23 09:00:00',
|
||||
'notes' => '商品推广',
|
||||
'expense' => 12.34,
|
||||
]],
|
||||
'total' => 1,
|
||||
'current_page' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => 20,
|
||||
'summary' => ['expense' => 12.34, 'count' => 1],
|
||||
'platform_summary' => [['platform' => '淘系', 'amount' => 12.34]],
|
||||
];
|
||||
$repository->shouldReceive('paginate')->once()->ordered()->with([
|
||||
'start' => '',
|
||||
'end' => '',
|
||||
'store_name' => '',
|
||||
'note' => '',
|
||||
], 1, 10)->andReturn($result);
|
||||
$repository->shouldReceive('storeSummary')->once()->ordered()->with([
|
||||
'start' => '2026-10-01',
|
||||
'end' => '2026-10-31',
|
||||
'store_name' => '',
|
||||
'note' => '推广',
|
||||
])->andReturn([
|
||||
'store_summary' => [[
|
||||
'platform' => '淘系',
|
||||
'store_name' => '测试推广店',
|
||||
'amount' => 12.34,
|
||||
]],
|
||||
'platform_summary' => [['platform' => '淘系', 'amount' => 12.34]],
|
||||
'total' => 12.34,
|
||||
]);
|
||||
$this->app->instance(PromotionMarketingExpenseRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->getJson(route('ecommerce-finance.account-bills.index', [
|
||||
'bill_type' => 'promotion-marketing',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total', 1)
|
||||
->assertJsonPath('summary.expense', 12.34)
|
||||
->assertJsonPath('columns.title', '推广费(营销账户)')
|
||||
->assertJsonPath('data.0.store_name', '测试推广店')
|
||||
->assertJsonPath('columns.columns.0.label', '店铺名称')
|
||||
->assertJsonPath('columns.columns.1.label', '交易时间')
|
||||
->assertJsonPath('columns.columns.2.label', '备注')
|
||||
->assertJsonPath('columns.columns.3.label', '支出金额');
|
||||
|
||||
$this->loginAs()
|
||||
->getJson(route('ecommerce-finance.account-bills.promotion-expense-details', [
|
||||
'bill_type' => 'promotion-marketing',
|
||||
'start' => '2026-10-01',
|
||||
'end' => '2026-10-31',
|
||||
'note' => '推广',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total', 1)
|
||||
->assertJsonPath('summary.expense', 12.34)
|
||||
->assertJsonPath('summary.count', 1)
|
||||
->assertJsonPath('platform_summary.0.platform', '淘系')
|
||||
->assertJsonPath('store_summary.0.platform', '淘系')
|
||||
->assertJsonPath('store_summary.0.store_name', '测试推广店')
|
||||
->assertJsonPath('store_summary.0.amount', 12.34);
|
||||
}
|
||||
|
||||
public function test_promotion_marketing_store_options_come_from_the_same_date_range(): void
|
||||
{
|
||||
$repository = Mockery::mock(PromotionMarketingExpenseRepository::class);
|
||||
$repository->shouldReceive('storeOptions')->once()->with([
|
||||
'start' => '2026-10-01',
|
||||
'end' => '2026-10-31',
|
||||
'store_name' => '',
|
||||
'note' => '',
|
||||
])->andReturn(['测试推广店', '另一推广店']);
|
||||
$this->app->instance(PromotionMarketingExpenseRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->getJson(route('ecommerce-finance.account-bills.filter-options', [
|
||||
'bill_type' => 'promotion-marketing',
|
||||
'start' => '2026-10-01',
|
||||
'end' => '2026-10-31',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('shops.0', '测试推广店')
|
||||
->assertJsonPath('shops.1', '另一推广店');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\PromotionMarketingExpenseRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PromotionMarketingExpenseRepositoryTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_it_merges_seven_sources_and_paginates_by_transaction_date(): void
|
||||
{
|
||||
$repository = $this->repository();
|
||||
$repository->histogramRows = [
|
||||
'tmall_spread_bills' => [['report_date' => '2026-10-23', 'row_count' => 1, 'expense' => 10]],
|
||||
'alibaba_bill' => [['report_date' => '2026-10-23', 'row_count' => 1, 'expense' => 20]],
|
||||
'pdd_spread_bills' => [['report_date' => '2026-10-23', 'row_count' => 1, 'expense' => 30]],
|
||||
'jd_jzt_finance_records' => [['report_date' => '2026-10-23', 'row_count' => 1, 'expense' => 40]],
|
||||
'dy_qianchuan_finance_transactions' => [['report_date' => '2026-10-23', 'row_count' => 1, 'expense' => 50]],
|
||||
'dw_ad_finance_records' => [['report_date' => '2026-10-23', 'row_count' => 1, 'expense' => 60]],
|
||||
'vip_marketingDailyReport' => [['report_date' => '2026-10-23', 'row_count' => 1, 'expense' => 70]],
|
||||
];
|
||||
$repository->detailRows = [
|
||||
'tmall_spread_bills' => [$this->row('天猫-卓禾家具旗舰店', '2026-10-23 09:00:00', '淘宝备注', null, 10)],
|
||||
'alibaba_bill' => [$this->row('阿里巴巴-常州曼向家居有限公司', '2026-10-23', '智能营销方案', null, 20)],
|
||||
'pdd_spread_bills' => [$this->row('拼多多-慕加家具旗舰店', '2026-10-23 09:30:00', '商品推广', '1', 30)],
|
||||
'jd_jzt_finance_records' => [$this->row('京东-亿家木家具旗舰店', '2026-10-23', '快车扣费', '推广费', 40)],
|
||||
'dy_qianchuan_finance_transactions' => [$this->row('抖音店', '2026-10-23', '千川推广', null, 50)],
|
||||
'dw_ad_finance_records' => [$this->row('得物-墨尚思', '2026-10-23', '得物推服务', '现金', 60)],
|
||||
'vip_marketingDailyReport' => [$this->row('唯品店', '2026-10-23', '推广费', null, 70)],
|
||||
];
|
||||
|
||||
$result = $repository->paginate([
|
||||
'start' => '2026-10-01',
|
||||
'end' => '2026-10-31',
|
||||
'store_name' => '',
|
||||
], 2, 2);
|
||||
|
||||
$this->assertSame(7, $result['total']);
|
||||
$this->assertSame(280.0, $result['summary']['expense']);
|
||||
$this->assertSame(4, $result['last_page']);
|
||||
$platformAmounts = array_column($result['platform_summary'], 'amount', 'platform');
|
||||
$this->assertSame(30.0, $platformAmounts['淘系']);
|
||||
$this->assertSame(30.0, $platformAmounts['拼多多']);
|
||||
$this->assertSame(40.0, $platformAmounts['京东']);
|
||||
$this->assertSame(50.0, $platformAmounts['抖音']);
|
||||
$this->assertSame(60.0, $platformAmounts['得物']);
|
||||
$this->assertSame(70.0, $platformAmounts['唯品会']);
|
||||
$this->assertSame(
|
||||
['拼多多-慕加家具旗舰店', '京东-亿家木家具旗舰店'],
|
||||
array_column($result['rows'], 'store_name'),
|
||||
);
|
||||
$this->assertSame('商品推广 · 现金', $result['rows'][0]['notes']);
|
||||
$this->assertSame('快车扣费 · 推广费', $result['rows'][1]['notes']);
|
||||
$this->assertSame([30.0, 40.0], array_column($result['rows'], 'expense'));
|
||||
$this->assertTrue($repository->sawSql('tmall_spread_bills', "finType] = 'CREDIT'"));
|
||||
$this->assertTrue($repository->sawSql('tmall_spread_bills', 'c.[shopName] AS store_name'));
|
||||
$this->assertTrue($repository->sawSql('alibaba_bill', 'c.[shopName] AS store_name'));
|
||||
$this->assertTrue($repository->sawSql('pdd_spread_bills', 'c.[shopName] AS store_name'));
|
||||
$this->assertTrue($repository->sawSql('pdd_spread_bills', 'flowType] = 2'));
|
||||
$this->assertTrue($repository->sawSql('jd_jzt_finance_records', "in_out_type] = 'OUT'"));
|
||||
$this->assertTrue($repository->sawSql('jd_jzt_finance_records', 'c.[shop_name] AS store_name'));
|
||||
$this->assertTrue($repository->sawSql('dw_ad_finance_records', 'trade_amount_yuan'));
|
||||
$this->assertTrue($repository->sawSql('dw_ad_finance_records', 'c.[shop_name] AS store_name'));
|
||||
$this->assertTrue($repository->sawSql('vip_marketingDailyReport', 'accountName'));
|
||||
}
|
||||
|
||||
public function test_store_options_are_merged_and_deduplicated(): void
|
||||
{
|
||||
$repository = $this->repository();
|
||||
$repository->storeRows = [
|
||||
'tmall_spread_bills' => [['store_name' => '乙店'], ['store_name' => '甲店']],
|
||||
'alibaba_bill' => [['store_name' => '甲店']],
|
||||
];
|
||||
|
||||
$this->assertSame(['乙店', '甲店'], $repository->storeOptions([
|
||||
'start' => '2026-10-01',
|
||||
'end' => '2026-10-31',
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_platform_and_store_summary_uses_the_installation_platform_categories(): void
|
||||
{
|
||||
$repository = $this->repository();
|
||||
$repository->storeSummaryRows = [
|
||||
'tmall_spread_bills' => [
|
||||
['store_name' => '天猫-甲店', 'amount' => 10],
|
||||
['store_name' => '天猫-乙店', 'amount' => 5],
|
||||
],
|
||||
'alibaba_bill' => [['store_name' => '阿里巴巴-甲店', 'amount' => 20]],
|
||||
'pdd_spread_bills' => [['store_name' => '拼多多-丙店', 'amount' => 30]],
|
||||
'jd_jzt_finance_records' => [['store_name' => '京东-丁店', 'amount' => 40]],
|
||||
'dy_qianchuan_finance_transactions' => [['store_name' => '抖音-戊店', 'amount' => 50]],
|
||||
'dw_ad_finance_records' => [['store_name' => '得物-己店', 'amount' => 60]],
|
||||
'vip_marketingDailyReport' => [['store_name' => '唯品-庚店', 'amount' => 70]],
|
||||
];
|
||||
|
||||
$result = $repository->storeSummary([
|
||||
'start' => '2026-10-01',
|
||||
'end' => '2026-10-31',
|
||||
]);
|
||||
|
||||
$this->assertSame(285.0, $result['total']);
|
||||
$amounts = array_column($result['platform_summary'], 'amount', 'platform');
|
||||
$this->assertSame(35.0, $amounts['淘系']);
|
||||
$this->assertSame(30.0, $amounts['拼多多']);
|
||||
$this->assertSame(40.0, $amounts['京东']);
|
||||
$this->assertSame(50.0, $amounts['抖音']);
|
||||
$this->assertSame(60.0, $amounts['得物']);
|
||||
$this->assertSame(70.0, $amounts['唯品会']);
|
||||
$this->assertContains(
|
||||
['platform' => '淘系', 'store_name' => '阿里巴巴-甲店', 'amount' => 20.0],
|
||||
$result['store_summary'],
|
||||
);
|
||||
$this->assertContains(
|
||||
['platform' => '唯品会', 'store_name' => '唯品会JITX_亿家达', 'amount' => 70.0],
|
||||
$result['store_summary'],
|
||||
);
|
||||
|
||||
$noteRepository = $this->repository();
|
||||
$noteRepository->storeSummaryRows = $repository->storeSummaryRows;
|
||||
$noteRepository->storeSummary([
|
||||
'start' => '2026-10-01',
|
||||
'end' => '2026-10-31',
|
||||
'note' => '推广',
|
||||
]);
|
||||
$this->assertTrue($noteRepository->sawSql(
|
||||
'dy_qianchuan_finance_transactions',
|
||||
"(c.[remark]) LIKE ? ESCAPE '!'",
|
||||
));
|
||||
}
|
||||
|
||||
private function repository(): PromotionMarketingExpenseRepository
|
||||
{
|
||||
return new class(Mockery::mock(UpstreamReadOnlyQueryFactory::class)) extends PromotionMarketingExpenseRepository {
|
||||
public array $histogramRows = [];
|
||||
|
||||
public array $detailRows = [];
|
||||
|
||||
public array $storeRows = [];
|
||||
|
||||
public array $storeSummaryRows = [];
|
||||
|
||||
public array $queries = [];
|
||||
|
||||
protected function select(string $connection, string $sql, array $bindings): array
|
||||
{
|
||||
$this->queries[] = compact('connection', 'sql', 'bindings');
|
||||
foreach (array_merge(array_keys($this->histogramRows), array_keys($this->detailRows), array_keys($this->storeRows), array_keys($this->storeSummaryRows)) as $table) {
|
||||
if (! str_contains($sql, $table)) {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($sql, 'GROUP BY') && str_contains($sql, 'AS store_name')) {
|
||||
return $this->storeSummaryRows[$table] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'GROUP BY')) {
|
||||
return $this->histogramRows[$table] ?? [];
|
||||
}
|
||||
if (str_contains($sql, 'DISTINCT TOP (500)')) {
|
||||
return $this->storeRows[$table] ?? [];
|
||||
}
|
||||
|
||||
return $this->detailRows[$table] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function sawSql(string $table, string $fragment): bool
|
||||
{
|
||||
foreach ($this->queries as $query) {
|
||||
if (str_contains($query['sql'], $table) && str_contains($query['sql'], $fragment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function row(
|
||||
string $store,
|
||||
string $occurredAt,
|
||||
string $primary,
|
||||
?string $secondary,
|
||||
float $expense,
|
||||
): array {
|
||||
return [
|
||||
'report_date' => substr($occurredAt, 0, 10),
|
||||
'store_name' => $store,
|
||||
'occurred_at' => $occurredAt,
|
||||
'note_primary' => $primary,
|
||||
'note_secondary' => $secondary,
|
||||
'expense' => $expense,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ class AccountBillsTableTest extends TestCase
|
||||
$this->assertStringContainsString("usesStableDateControl(){return true;}", $source);
|
||||
$this->assertStringContainsString("{ 'is-stable-date': usesStableDateControl }", $source);
|
||||
$this->assertStringContainsString('.tw-date.is-stable-date .tw-date-control', $source);
|
||||
$this->assertStringContainsString("['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jingxi','jd-wallet','vipshop','vipshop-jx'].indexOf(this.billType)!==-1", $source);
|
||||
$this->assertStringContainsString("['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jingxi','jd-wallet','vipshop','vipshop-jx','promotion-marketing'].indexOf(this.billType)!==-1", $source);
|
||||
$this->assertStringContainsString('v-if="usesMirrorOptions && !filterOptionsError"', $source);
|
||||
$this->assertStringContainsString('page-key="store-receiving-bills"', $source);
|
||||
$this->assertStringContainsString(':tab-key="billType"', $source);
|
||||
@ -30,11 +30,11 @@ class AccountBillsTableTest extends TestCase
|
||||
$source = file_get_contents(__DIR__ . '/../../../resources/assets/js/views/business/pages.js');
|
||||
|
||||
$this->assertStringContainsString(
|
||||
"const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx'];",
|
||||
"const GENERIC_TABS = ['taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx', 'promotion-marketing'];",
|
||||
$source,
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
"const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx'];",
|
||||
"const STORE_RECEIVING_TABS = ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet', 'jd-transactions', 'jingxi', 'douyin', 'dewu', 'vipshop', 'vipshop-jx', 'promotion-marketing'];",
|
||||
$source,
|
||||
);
|
||||
}
|
||||
@ -46,9 +46,34 @@ class AccountBillsTableTest extends TestCase
|
||||
$this->assertStringContainsString("const JD_BILL_TABS = ['jd-transactions','jingxi'];", $source);
|
||||
$this->assertStringContainsString("isJdBillTab(){return JD_BILL_TABS.indexOf(this.billType)!==-1;}", $source);
|
||||
// 唯品会两个页签的筛选标签是「账单期间」(后端也只按 payload.settle_period 过滤)。
|
||||
$this->assertStringContainsString("const DATE_FILTER_LABELS = {'jd-transactions':'费用结算时间','jingxi':'费用结算时间','jd-wallet':'费用结算时间','douyin':'结算时间','vipshop':'账单期间','vipshop-jx':'账单期间'};", $source);
|
||||
$this->assertStringContainsString("const DATE_FILTER_LABELS = {'jd-transactions':'费用结算时间','jingxi':'费用结算时间','jd-wallet':'费用结算时间','douyin':'结算时间','vipshop':'账单期间','vipshop-jx':'账单期间','promotion-marketing':'交易时间'};", $source);
|
||||
$this->assertStringContainsString('{{ dateFilterLabel }}', $source);
|
||||
$this->assertStringContainsString('v-if="isJdBillTab && sync"', $source);
|
||||
$this->assertStringContainsString("netLabel=JD_BILL_TABS.concat(['vipshop-jx']).indexOf(this.billType)!==-1", $source);
|
||||
}
|
||||
|
||||
public function test_promotion_marketing_card_opens_the_store_detail_modal(): void
|
||||
{
|
||||
$source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue');
|
||||
|
||||
$this->assertStringContainsString('推广费(营销账户)', $source);
|
||||
$this->assertStringContainsString("label:'推广费(营销账户)'", $source);
|
||||
$this->assertStringContainsString('查看明细 →', $source);
|
||||
$this->assertStringContainsString('aria-labelledby="promotion-expense-detail-title"', $source);
|
||||
$this->assertStringContainsString('<strong id="promotion-expense-detail-title">推广费(营销账户) · 明细汇总</strong>', $source);
|
||||
$this->assertStringContainsString('>平台汇总</button>', $source);
|
||||
$this->assertStringContainsString('>店铺明细</button>', $source);
|
||||
$this->assertStringContainsString('aria-label="筛选平台"', $source);
|
||||
$this->assertStringContainsString('aria-label="筛选店铺名称"', $source);
|
||||
$this->assertStringContainsString('aria-label="筛选备注"', $source);
|
||||
$this->assertStringContainsString('<th scope="col">平台</th>', $source);
|
||||
$this->assertStringContainsString('<th scope="col">店铺名称</th>', $source);
|
||||
$this->assertStringContainsString('<th scope="col" class="is-number">推广费金额(万元)</th>', $source);
|
||||
$this->assertStringContainsString('promotionFilteredPlatformRows', $source);
|
||||
$this->assertStringContainsString('promotionFilteredStoreRows', $source);
|
||||
$this->assertStringContainsString('tw-stats--promotion-only', $source);
|
||||
$this->assertStringContainsString('.tw-stats--promotion-only{grid-template-columns:minmax(0,1fr)}', $source);
|
||||
$this->assertStringContainsString('class="tw-date-icon"', $source);
|
||||
$this->assertStringContainsString("if(this.isPromotionMarketing){\n return [{label:'推广费(营销账户)'", $source);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user