feat: 推广费营销账户明细汇总

新增推广费(营销账户)页签、平台与原始店铺汇总、备注筛选和明细弹窗。
This commit is contained in:
qiuxiang 2026-10-27 19:27:41 +08:00
parent 26c86df915
commit 3be1639fb2
9 changed files with 1251 additions and 25 deletions

View File

@ -554,6 +554,7 @@ $navigation = [
['slug' => 'dewu', 'title' => '得物账户'],
['slug' => 'vipshop', 'title' => '唯品会MP账户'],
['slug' => 'vipshop-jx', 'title' => '唯品会JX账户'],
['slug' => 'promotion-marketing', 'title' => '推广费(营销账户)'],
],
],
[

View File

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

View File

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

View File

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

View File

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

View File

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

View 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', '另一推广店');
}
}

View File

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

View File

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