商品推广费用明细:店铺筛选改成和订单售后费用明细一致的多选控件

- 把「店铺名称:可点选的多选 + 模糊搜索」抽成公共组件
  partials/order-data-shop-picker.blade.php,订单售后费用明细改为引用它,
  商品推广费用明细用同一个控件(原来是一个纯文本输入框)。
- 推广页提交 shop_name[](多个取并集),后端镜像查询和非淘系旧查询都改成
  按多个店铺 OR 匹配,仍兼容原来的单个 shop_name。
- 推广表的店铺名带下划线(天猫_卓禾家具旗舰店),和订单镜像的「天猫-…」
  不是同一套命名,所以候选单独取自 ops_item_sales_cost_ratio 当前统计日期
  出现过的店铺,按日期缓存 24 小时(单次约 0.4 秒、约 400 个店铺)。
This commit is contained in:
renshen 2026-10-07 13:39:22 +08:00
parent 2a30d9bac3
commit 4b97b30a3a
8 changed files with 561 additions and 346 deletions

View File

@ -83,6 +83,82 @@ class OrderDataMirrorRepository
return $this->normalizeShopNames($rows);
}
/**
* 商品推广费用明细的店铺候选:指定统计日期出现过的店铺。
*
* 推广表的店铺名带下划线("天猫_卓禾家具旗舰店"),和订单镜像的"天猫-…"不是同一套
* 命名,不能复用订单店铺候选;按日期过滤后有 400 行左右,结果缓存 24 小时。
*
* @return array<int,string>
*/
public function promotionShopNames(string $date): array
{
$date = trim($date);
if ($date === '') {
return [];
}
$key = 'order-data.promotion.shop-options:' . $date;
if (is_array($cached = Cache::get($key))) {
return array_values($cached);
}
try {
$names = $this->connection->table('ops_item_sales_cost_ratio')
->select('shop_name')
->distinct()
->where('stat_date', '>=', $date . ' 00:00:00')
->where('stat_date', '<', date('Y-m-d 00:00:00', strtotime($date . ' +1 day')))
->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->orderBy('shop_name')
->pluck('shop_name');
} catch (Throwable $exception) {
report($exception);
return [];
}
$names = $this->normalizeShopNames($names);
if ($names !== []) {
Cache::put($key, $names, now()->addHours(24));
}
return $names;
}
/**
* 店铺筛选支持多选(shop_name[]=A&shop_name[]=B),也兼容原来的单个 shop_name。
*
* @return list<string>
*/
private function selectedShopNames(array $filters): array
{
$values = $filters['shop_names'] ?? [];
if (! is_array($values) || $values === []) {
$values = ($filters['shop_name'] ?? '') !== '' ? [$filters['shop_name']] : [];
}
$names = [];
foreach ($values as $value) {
if (! is_scalar($value)) {
continue;
}
$name = trim((string) $value);
if ($name !== '') {
$names[$name] = true;
}
// 与页面上的多选上限一致,避免拼出过长的 WHERE。
if (count($names) >= 50) {
break;
}
}
return array_keys($names);
}
private function warmShopNamesCache(): void
{
app()->terminating(function (): void {
@ -209,8 +285,14 @@ class OrderDataMirrorRepository
->where('c.stat_date', '>=', $date . ' 00:00:00')
->where('c.stat_date', '<', date('Y-m-d 00:00:00', strtotime($date . ' +1 day')))
->where('c.push_fee', '<>', 0);
if (($filters['shop_name'] ?? '') !== '') {
$query->whereRaw("c.shop_name LIKE ? ESCAPE '!'", [$this->contains((string) $filters['shop_name'])]);
$shopNames = $this->selectedShopNames($filters);
if ($shopNames !== []) {
// 多店铺:每个店铺做一次模糊匹配再取并集,和订单售后费用明细的口径一致。
$query->where(function (Builder $shop) use ($shopNames): void {
foreach ($shopNames as $name) {
$shop->orWhereRaw("c.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
}
});
}
if (($filters['sku_id'] ?? '') !== '') {
$needle = $this->contains((string) $filters['sku_id']);
@ -356,14 +438,7 @@ class OrderDataMirrorRepository
$query->whereIn('o.o_id', $orderIds);
}
}
$shopNames = array_values(array_filter(array_map(
static fn (mixed $name): string => trim((string) $name),
(array) ($filters['shop_names'] ?? []),
), static fn (string $name): bool => $name !== ''));
if ($shopNames === [] && ($filters['shop_name'] ?? '') !== '') {
$shopNames = [trim((string) $filters['shop_name'])];
}
$shopNames = $this->selectedShopNames($filters);
if ($shopNames !== []) {
// 多店铺:每个店铺做一个模糊匹配再取并集,选中的是完整店铺名时等同于精确匹配。

View File

@ -194,9 +194,18 @@ class OrderDataRepository
$where = ['c.[stat_date] = CAST(? AS date)', 'ISNULL(c.[push_fee], 0) <> 0'];
$bindings = [$date];
if (($filters['shop_name'] ?? '') !== '') {
$where[] = "c.[shop_name] LIKE ? ESCAPE '\\'";
$bindings[] = $this->contains($filters['shop_name']);
$shopNames = array_values(array_filter(array_map(
static fn (mixed $name): string => trim((string) $name),
(array) ($filters['shop_names'] ?? []),
), static fn (string $name): bool => $name !== ''));
if ($shopNames === [] && ($filters['shop_name'] ?? '') !== '') {
$shopNames = [trim((string) $filters['shop_name'])];
}
if ($shopNames !== []) {
$where[] = '(' . implode(' OR ', array_fill(0, count($shopNames), "c.[shop_name] LIKE ? ESCAPE '\\'")) . ')';
foreach ($shopNames as $name) {
$bindings[] = $this->contains($name);
}
}
if (($filters['sku_id'] ?? '') !== '') {

View File

@ -116,6 +116,24 @@ class OrderDataPageData
return array_keys($names);
}
/**
* 商品推广费用明细的店铺候选:该统计日期出现过的店铺。
*
* @return array<int,string>
*/
private function promotionShopOptions(OrderDataMirrorRepository $mirror, string $date): array
{
try {
$names = $mirror->promotionShopNames($date);
return is_array($names) ? array_values($names) : [];
} catch (Throwable $exception) {
report($exception);
return [];
}
}
/**
* 店铺多选候选:订单镜像里出现过的店铺名称。
*
@ -179,9 +197,12 @@ class OrderDataPageData
public function promotionExpenses(): array
{
// 店铺筛选与订单售后费用明细一致:多选 shop_name[],兼容原来的单个 shop_name。
$shopNames = $this->shopNames(request('shop_name') ?? request('shop_names'));
$filters = [
'date' => $this->date(request('date')),
'shop_name' => $this->text(request('shop_name')),
'shop_name' => $shopNames[0] ?? '',
'shop_names' => $shopNames,
'sku_id' => $this->text(request('sku_id')),
];
[$page, $limit] = $this->pageSettings();
@ -190,10 +211,12 @@ class OrderDataPageData
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
if ($mirror->promotionReady()) {
$result = $mirror->promotionExpenses($filters, $page, $limit);
$resolvedDate = (string) ($result['date'] ?? $filters['date']);
return [
'orderDataRows' => $this->paginator((array) ($result['rows'] ?? []), $page, $limit),
'orderDataFilters' => array_replace($filters, ['date' => $result['date'] ?? $filters['date']]),
'orderDataFilters' => array_replace($filters, ['date' => $resolvedDate]),
'orderDataShopOptions' => $this->promotionShopOptions($mirror, $resolvedDate),
'orderDataStatus' => ['X-Caiwu-Data-Status' => 'mirror'],
'orderDataError' => null,
];
@ -209,6 +232,7 @@ class OrderDataPageData
return [
'orderDataRows' => $this->paginator((array) ($result['rows'] ?? []), $page, $limit),
'orderDataFilters' => array_replace($filters, ['date' => $result['date'] ?? $filters['date']]),
'orderDataShopOptions' => [],
'orderDataStatus' => $cache->responseHeaders(),
'orderDataError' => null,
];

View File

@ -183,155 +183,6 @@
font-size: 1.125rem;
}
/* 店铺名称:可点选的多选下拉(模糊搜索 + 多选),沿用黑金设计令牌 */
.order-data-shop { position: relative; }
.order-data-shop__toggle {
display: inline-flex;
width: 100%;
min-width: 12rem;
height: 2.125rem;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0.65rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
}
.order-data-shop__toggle:hover { border-color: var(--vip-content-accent, #b9954f); }
.order-data-shop__toggle[aria-expanded="true"],
.order-data-shop__toggle:focus-visible {
border-color: var(--vip-content-accent-dark, #856632);
outline: none;
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.order-data-shop__toggle.is-set {
color: var(--vip-content-accent-dark, #856632);
font-weight: 600;
}
.order-data-shop__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-data-shop__toggle .material-icons-outlined {
flex: 0 0 auto;
font-size: 1.125rem;
color: var(--vip-content-muted, #595959);
}
.order-data-shop__panel {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
display: flex;
width: min(22rem, 86vw);
flex-direction: column;
gap: 0.5rem;
padding: 0.65rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10);
}
.order-data-shop__panel[hidden] { display: none; }
.order-data-shop .order-data-shop__search {
height: 2rem;
padding: 0 0.55rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
color: var(--vip-content-ink, #171717);
font-size: 0.8125rem;
}
.order-data-shop .order-data-shop__search:focus {
border-color: var(--vip-content-accent-dark, #856632);
outline: none;
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.order-data-shop__actions { display: flex; gap: 0.4rem; }
.order-data-shop__actions button {
height: 1.75rem;
padding: 0 0.5rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font-size: 0.75rem;
cursor: pointer;
}
.order-data-shop__actions button:hover {
border-color: var(--vip-content-accent-dark, #856632);
color: var(--vip-content-accent-dark, #856632);
}
.order-data-shop__options {
display: flex;
max-height: 16rem;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.order-data-shop__option {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.4rem;
border-radius: 0.375rem;
color: var(--vip-content-ink, #171717);
font-size: 0.8125rem;
cursor: pointer;
}
.order-data-shop__option:hover { background: var(--vip-content-accent-soft, #f7f0de); }
/* 覆盖 .upstream-data-field input 的通用输入框样式,复选框不能撑成整行输入框 */
.order-data-shop .order-data-shop__option input[type="checkbox"] {
flex: 0 0 auto;
width: 0.95rem;
height: 0.95rem;
min-width: 0.95rem;
margin: 0;
padding: 0;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.25rem;
background: var(--vip-surface, #ffffff);
accent-color: var(--vip-content-accent-dark, #856632);
}
.order-data-shop__option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-data-shop__option.is-selected { background: var(--vip-content-accent-soft, #f7f0de); }
.order-data-shop__hint,
.order-data-shop__empty {
padding: 0.35rem 0.4rem;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
}
.order-data-shop__hint[hidden] { display: none; }
</style>
<form method="GET" class="upstream-data-toolbar" aria-label="订单售后费用明细筛选">
@ -365,27 +216,10 @@
<label for="order-data-order-no">订单号</label>
<input id="order-data-order-no" name="order_no" type="text" value="{{ $filters['order_no'] ?? '' }}" placeholder="内部、线上或子订单号">
</div>
<div class="upstream-data-field">
<label for="order-data-shop-toggle">店铺名称</label>
<div class="order-data-shop" data-shop-picker>
<button type="button" id="order-data-shop-toggle" class="order-data-shop__toggle"
data-shop-toggle aria-haspopup="true" aria-expanded="false">
<span class="order-data-shop__label" data-shop-label>全部</span>
<span class="material-icons-outlined" aria-hidden="true">expand_more</span>
</button>
<div class="order-data-shop__panel" data-shop-panel hidden>
<input type="search" class="order-data-shop__search" data-shop-search
placeholder="输入关键字筛选店铺" aria-label="搜索店铺">
<div class="order-data-shop__actions">
<button type="button" data-shop-action="all">全选当前结果</button>
<button type="button" data-shop-action="none">清空</button>
</div>
<div class="order-data-shop__options" data-shop-options role="group" aria-label="店铺候选"></div>
<div class="order-data-shop__hint" data-shop-hint hidden></div>
</div>
<span data-shop-inputs></span>
</div>
</div>
@include('business.pages.partials.order-data-shop-picker', [
'shopPickerOptions' => $orderData['orderDataShopOptions'] ?? [],
'shopPickerSelected' => $filters['shop_names'] ?? [],
])
<div class="upstream-data-field">
<label for="order-data-sku">商品编码</label>
<input id="order-data-sku" name="sku_id" type="text" value="{{ $filters['sku_id'] ?? '' }}" placeholder="商品、款式或店铺编码">
@ -646,160 +480,3 @@
})();
</script>
<script>
// 店铺名称:可点选的多选下拉,支持在候选里模糊搜索,提交为 shop_name[](多个取并集)。
(() => {
const picker = document.querySelector('[data-shop-picker]');
if (!picker) {
return;
}
const options = @json(array_values($orderData['orderDataShopOptions'] ?? []), JSON_UNESCAPED_UNICODE);
const chosen = @json(array_values($filters['shop_names'] ?? []), JSON_UNESCAPED_UNICODE);
const maxRender = 300;
// 与后端 shopNames() 的上限一致:一次最多筛 50 个店铺,避免 URL 过长和查询过慢。
const maxSelect = 50;
const toggle = picker.querySelector('[data-shop-toggle]');
const label = picker.querySelector('[data-shop-label]');
const panel = picker.querySelector('[data-shop-panel]');
const search = picker.querySelector('[data-shop-search]');
const list = picker.querySelector('[data-shop-options]');
const hint = picker.querySelector('[data-shop-hint]');
const inputs = picker.querySelector('[data-shop-inputs]');
let keyword = '';
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[ch]));
function listed() {
if (!keyword) {
return options.slice();
}
return options.filter((name) => String(name).toLowerCase().includes(keyword));
}
function renderOptions() {
const items = listed();
if (!items.length) {
list.innerHTML = '<div class="order-data-shop__empty">没有匹配的店铺,换个关键字试试</div>';
} else {
list.innerHTML = items.slice(0, maxRender).map((name) => {
const active = chosen.indexOf(name) >= 0;
return '<label class="order-data-shop__option' + (active ? ' is-selected' : '') + '">'
+ '<input type="checkbox" value="' + escapeHtml(name) + '"' + (active ? ' checked' : '') + '>'
+ '<span>' + escapeHtml(name) + '</span></label>';
}).join('');
}
const overflow = items.length - maxRender;
hint.hidden = overflow <= 0;
if (overflow > 0) {
hint.textContent = '还有 ' + overflow + ' 个店铺,输入更精确的关键字再选';
}
}
function showLimitHint() {
hint.hidden = false;
hint.textContent = '一次最多筛选 ' + maxSelect + ' 个店铺,请先清空或缩小关键字范围。';
}
function sync() {
inputs.innerHTML = chosen
.map((name) => '<input type="hidden" name="shop_name[]" value="' + escapeHtml(name) + '">')
.join('');
if (!chosen.length) {
label.textContent = '全部';
toggle.classList.remove('is-set');
toggle.title = '全部店铺';
} else if (chosen.length === 1) {
label.textContent = chosen[0];
toggle.classList.add('is-set');
toggle.title = chosen[0];
} else {
label.textContent = chosen[0] + ' 等 ' + chosen.length + ' 个店铺';
toggle.classList.add('is-set');
toggle.title = chosen.join('、');
}
}
function closePanel() {
panel.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
}
function openPanel() {
panel.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
search.focus();
}
toggle.addEventListener('click', () => (panel.hidden ? openPanel() : closePanel()));
search.addEventListener('input', () => {
keyword = search.value.trim().toLowerCase();
renderOptions();
});
list.addEventListener('change', (event) => {
const checkbox = event.target.closest('input[type="checkbox"]');
if (!checkbox) {
return;
}
const index = chosen.indexOf(checkbox.value);
if (checkbox.checked && index < 0) {
if (chosen.length >= maxSelect) {
checkbox.checked = false;
showLimitHint();
return;
}
chosen.push(checkbox.value);
} else if (!checkbox.checked && index >= 0) {
chosen.splice(index, 1);
}
checkbox.closest('.order-data-shop__option')?.classList.toggle('is-selected', checkbox.checked);
sync();
});
panel.querySelector('[data-shop-action="all"]').addEventListener('click', () => {
const items = listed().slice(0, maxRender);
items.forEach((name) => {
if (chosen.length < maxSelect && chosen.indexOf(name) < 0) {
chosen.push(name);
}
});
renderOptions();
if (items.length > maxSelect) {
showLimitHint();
}
sync();
});
panel.querySelector('[data-shop-action="none"]').addEventListener('click', () => {
chosen.length = 0;
renderOptions();
sync();
});
document.addEventListener('click', (event) => {
if (!picker.contains(event.target)) {
closePanel();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closePanel();
}
});
renderOptions();
sync();
})();
</script>

View File

@ -23,10 +23,11 @@
<label for="promotion-expense-date">统计日期</label>
<input id="promotion-expense-date" name="date" type="date" value="{{ $filters['date'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="promotion-expense-shop">店铺名称</label>
<input id="promotion-expense-shop" name="shop_name" type="text" value="{{ $filters['shop_name'] ?? '' }}">
</div>
@include('business.pages.partials.order-data-shop-picker', [
'shopPickerOptions' => $orderData['orderDataShopOptions'] ?? [],
'shopPickerSelected' => $filters['shop_names'] ?? [],
'shopPickerToggleId' => 'promotion-expense-shop',
])
<div class="upstream-data-field">
<label for="promotion-expense-sku">商品编码</label>
<input id="promotion-expense-sku" name="sku_id" type="text" value="{{ $filters['sku_id'] ?? '' }}" placeholder="商品、款式或店铺编码">

View File

@ -0,0 +1,345 @@
{{--
店铺名称:可点选的多选下拉,支持在候选里模糊搜索,提交为 shop_name[](多个取并集)。
订单售后费用明细和商品推广费用明细共用这套控件。
需要传入:
$shopPickerOptions 候选店铺名(list<string>)
$shopPickerSelected 已选店铺名(list<string>)
--}}
@php
$shopPickerOptions = array_values($shopPickerOptions ?? []);
$shopPickerSelected = array_values($shopPickerSelected ?? []);
$shopPickerToggleId = $shopPickerToggleId ?? 'order-data-shop-toggle';
@endphp
<style>
/* 店铺名称:可点选的多选下拉(模糊搜索 + 多选),沿用黑金设计令牌 */
.order-data-shop { position: relative; }
.order-data-shop__toggle {
display: inline-flex;
width: 100%;
min-width: 12rem;
height: 2.125rem;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0.65rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
}
.order-data-shop__toggle:hover { border-color: var(--vip-content-accent, #b9954f); }
.order-data-shop__toggle[aria-expanded="true"],
.order-data-shop__toggle:focus-visible {
border-color: var(--vip-content-accent-dark, #856632);
outline: none;
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.order-data-shop__toggle.is-set {
color: var(--vip-content-accent-dark, #856632);
font-weight: 600;
}
.order-data-shop__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-data-shop__toggle .material-icons-outlined {
flex: 0 0 auto;
font-size: 1.125rem;
color: var(--vip-content-muted, #595959);
}
.order-data-shop__panel {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
display: flex;
width: min(22rem, 86vw);
flex-direction: column;
gap: 0.5rem;
padding: 0.65rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10);
}
.order-data-shop__panel[hidden] { display: none; }
.order-data-shop .order-data-shop__search {
height: 2rem;
padding: 0 0.55rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
color: var(--vip-content-ink, #171717);
font-size: 0.8125rem;
}
.order-data-shop .order-data-shop__search:focus {
border-color: var(--vip-content-accent-dark, #856632);
outline: none;
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.order-data-shop__actions { display: flex; gap: 0.4rem; }
.order-data-shop__actions button {
height: 1.75rem;
padding: 0 0.5rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font-size: 0.75rem;
cursor: pointer;
}
.order-data-shop__actions button:hover {
border-color: var(--vip-content-accent-dark, #856632);
color: var(--vip-content-accent-dark, #856632);
}
.order-data-shop__options {
display: flex;
max-height: 16rem;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.order-data-shop__option {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.4rem;
border-radius: 0.375rem;
color: var(--vip-content-ink, #171717);
font-size: 0.8125rem;
cursor: pointer;
}
.order-data-shop__option:hover { background: var(--vip-content-accent-soft, #f7f0de); }
/* 覆盖 .upstream-data-field input 的通用输入框样式,复选框不能撑成整行输入框 */
.order-data-shop .order-data-shop__option input[type="checkbox"] {
flex: 0 0 auto;
width: 0.95rem;
height: 0.95rem;
min-width: 0.95rem;
margin: 0;
padding: 0;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.25rem;
background: var(--vip-surface, #ffffff);
accent-color: var(--vip-content-accent-dark, #856632);
}
.order-data-shop__option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-data-shop__option.is-selected { background: var(--vip-content-accent-soft, #f7f0de); }
.order-data-shop__hint,
.order-data-shop__empty {
padding: 0.35rem 0.4rem;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
}
.order-data-shop__hint[hidden] { display: none; }
</style>
<div class="upstream-data-field">
<label for="{{ $shopPickerToggleId }}">店铺名称</label>
<div class="order-data-shop" data-shop-picker>
<button type="button" id="{{ $shopPickerToggleId }}" class="order-data-shop__toggle"
data-shop-toggle aria-haspopup="true" aria-expanded="false">
<span class="order-data-shop__label" data-shop-label>全部</span>
<span class="material-icons-outlined" aria-hidden="true">expand_more</span>
</button>
<div class="order-data-shop__panel" data-shop-panel hidden>
<input type="search" class="order-data-shop__search" data-shop-search
placeholder="输入关键字筛选店铺" aria-label="搜索店铺">
<div class="order-data-shop__actions">
<button type="button" data-shop-action="all">全选当前结果</button>
<button type="button" data-shop-action="none">清空</button>
</div>
<div class="order-data-shop__options" data-shop-options role="group" aria-label="店铺候选"></div>
<div class="order-data-shop__hint" data-shop-hint hidden></div>
</div>
<span data-shop-inputs></span>
</div>
</div>
<script>
// 店铺名称:可点选的多选下拉,支持在候选里模糊搜索,提交为 shop_name[](多个取并集)。
(() => {
const picker = document.querySelector('[data-shop-picker]');
if (!picker) {
return;
}
const options = @json($shopPickerOptions, JSON_UNESCAPED_UNICODE);
const chosen = @json($shopPickerSelected, JSON_UNESCAPED_UNICODE);
const maxRender = 300;
// 与后端 shopNames() 的上限一致:一次最多筛 50 个店铺,避免 URL 过长和查询过慢。
const maxSelect = 50;
const toggle = picker.querySelector('[data-shop-toggle]');
const label = picker.querySelector('[data-shop-label]');
const panel = picker.querySelector('[data-shop-panel]');
const search = picker.querySelector('[data-shop-search]');
const list = picker.querySelector('[data-shop-options]');
const hint = picker.querySelector('[data-shop-hint]');
const inputs = picker.querySelector('[data-shop-inputs]');
let keyword = '';
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[ch]));
function listed() {
if (!keyword) {
return options.slice();
}
return options.filter((name) => String(name).toLowerCase().includes(keyword));
}
function renderOptions() {
const items = listed();
if (!items.length) {
list.innerHTML = '<div class="order-data-shop__empty">没有匹配的店铺,换个关键字试试</div>';
} else {
list.innerHTML = items.slice(0, maxRender).map((name) => {
const active = chosen.indexOf(name) >= 0;
return '<label class="order-data-shop__option' + (active ? ' is-selected' : '') + '">'
+ '<input type="checkbox" value="' + escapeHtml(name) + '"' + (active ? ' checked' : '') + '>'
+ '<span>' + escapeHtml(name) + '</span></label>';
}).join('');
}
const overflow = items.length - maxRender;
hint.hidden = overflow <= 0;
if (overflow > 0) {
hint.textContent = '还有 ' + overflow + ' 个店铺,输入更精确的关键字再选';
}
}
function showLimitHint() {
hint.hidden = false;
hint.textContent = '一次最多筛选 ' + maxSelect + ' 个店铺,请先清空或缩小关键字范围。';
}
function sync() {
inputs.innerHTML = chosen
.map((name) => '<input type="hidden" name="shop_name[]" value="' + escapeHtml(name) + '">')
.join('');
if (!chosen.length) {
label.textContent = '全部';
toggle.classList.remove('is-set');
toggle.title = '全部店铺';
} else if (chosen.length === 1) {
label.textContent = chosen[0];
toggle.classList.add('is-set');
toggle.title = chosen[0];
} else {
label.textContent = chosen[0] + ' 等 ' + chosen.length + ' 个店铺';
toggle.classList.add('is-set');
toggle.title = chosen.join('、');
}
}
function closePanel() {
panel.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
}
function openPanel() {
panel.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
search.focus();
}
toggle.addEventListener('click', () => (panel.hidden ? openPanel() : closePanel()));
search.addEventListener('input', () => {
keyword = search.value.trim().toLowerCase();
renderOptions();
});
list.addEventListener('change', (event) => {
const checkbox = event.target.closest('input[type="checkbox"]');
if (!checkbox) {
return;
}
const index = chosen.indexOf(checkbox.value);
if (checkbox.checked && index < 0) {
if (chosen.length >= maxSelect) {
checkbox.checked = false;
showLimitHint();
return;
}
chosen.push(checkbox.value);
} else if (!checkbox.checked && index >= 0) {
chosen.splice(index, 1);
}
checkbox.closest('.order-data-shop__option')?.classList.toggle('is-selected', checkbox.checked);
sync();
});
panel.querySelector('[data-shop-action="all"]').addEventListener('click', () => {
const items = listed().slice(0, maxRender);
items.forEach((name) => {
if (chosen.length < maxSelect && chosen.indexOf(name) < 0) {
chosen.push(name);
}
});
renderOptions();
if (items.length > maxSelect) {
showLimitHint();
}
sync();
});
panel.querySelector('[data-shop-action="none"]').addEventListener('click', () => {
chosen.length = 0;
renderOptions();
sync();
});
document.addEventListener('click', (event) => {
if (!picker.contains(event.target)) {
closePanel();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closePanel();
}
});
renderOptions();
sync();
})();
</script>

View File

@ -97,6 +97,49 @@ class OrderDataPagesUpstreamTest extends FeatureTestCase
->assertSeeText('ORDER-MULTI-1');
}
public function test_promotion_page_offers_the_same_selectable_multi_shop_filter(): void
{
$this->allowOrderDataPages();
$mirror = Mockery::mock(OrderDataMirrorRepository::class);
$mirror->shouldReceive('ready')->zeroOrMoreTimes()->andReturnTrue();
$mirror->shouldReceive('promotionReady')->zeroOrMoreTimes()->andReturnTrue();
$mirror->shouldReceive('promotionShopNames')->once()->with('2026-08-13')
->andReturn(['天猫_甲店', '京东_乙店']);
$mirror->shouldReceive('promotionExpenses')->once()
->with(
Mockery::on(static fn (array $filters): bool => ($filters['shop_names'] ?? null) === ['天猫_甲店', '京东_乙店']),
1,
25,
)
->andReturn(['date' => '2026-08-13', 'rows' => [[
'shop_name' => '天猫_甲店',
'product_code' => null,
'style_code' => null,
'shop_product_code' => 'ITEM-1',
'shop_style_code' => 'ITEM-1',
'online_color_spec' => null,
'promotion_fee' => 12.5,
]]]);
$this->app->instance(OrderDataMirrorRepository::class, $mirror);
$factory = Mockery::mock(UpstreamQueryCacheFactory::class);
$factory->shouldNotReceive('forSource');
$this->app->instance(UpstreamQueryCacheFactory::class, $factory);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'order-data',
'page' => 'raw-data',
'tab' => 'promotion-expenses',
'date' => '2026-08-13',
'shop_name' => ['天猫_甲店', '京东_乙店'],
]))
->assertOk()
->assertSee('data-shop-picker', false)
->assertSee('data-shop-options', false)
->assertSee('name="shop_name[]"', false)
->assertSeeText('天猫_甲店')
->assertSeeText('12.5');
}
public function test_after_sales_filter_normalization_keeps_defaults_and_clears_on_reset(): void
{
$data = app(\Modules\EcommerceFinance\View\OrderDataPageData::class);

View File

@ -198,6 +198,47 @@ class OrderDataMirrorRepositoryTest extends TestCase
$this->assertContains('天猫-卓禾家具旗舰店', $names);
}
public function test_promotion_expenses_filters_by_multiple_shops(): void
{
$db = DB::connection('business_mirror');
foreach ([['天猫_甲店', 'ITEM-1', 5.5], ['京东_乙店', 'ITEM-2', 3.0], ['拼多多_丙店', 'ITEM-3', 1.0]] as [$shop, $item, $fee]) {
$db->table('ops_item_sales_cost_ratio')->insert([
'stat_date' => '2026-08-13 00:00:00', 'shop_name' => $shop, 'item_id' => $item, 'push_fee' => $fee,
]);
}
$repository = $this->repository($db);
$all = $repository->promotionExpenses(['date' => '2026-08-13', 'shop_names' => []], 1, 25);
$this->assertSame(['天猫_甲店', '京东_乙店', '拼多多_丙店'], array_column($all['rows'], 'shop_name'));
$multi = $repository->promotionExpenses(
['date' => '2026-08-13', 'shop_names' => ['天猫_甲店', '拼多多_丙店']],
1,
25,
);
$this->assertSame(['天猫_甲店', '拼多多_丙店'], array_column($multi['rows'], 'shop_name'));
// 兼容原来的单值 shop_name。
$legacy = $repository->promotionExpenses(['date' => '2026-08-13', 'shop_name' => '京东_乙店'], 1, 25);
$this->assertSame(['京东_乙店'], array_column($legacy['rows'], 'shop_name'));
}
public function test_promotion_shop_names_lists_the_shops_of_the_selected_date(): void
{
$db = DB::connection('business_mirror');
foreach ([['天猫_甲店', '2026-08-13'], ['京东_乙店', '2026-08-13'], ['天猫_甲店', '2026-08-12']] as [$shop, $date]) {
$db->table('ops_item_sales_cost_ratio')->insert([
'stat_date' => $date . ' 00:00:00', 'shop_name' => $shop, 'item_id' => 'ITEM-1', 'push_fee' => 1.0,
]);
}
$repository = $this->repository($db);
$this->assertSame(['京东_乙店', '天猫_甲店'], $repository->promotionShopNames('2026-08-13'));
$this->assertSame([], $repository->promotionShopNames(''));
}
public function test_after_sales_order_number_is_resolved_to_candidates_before_filtering(): void
{
$db = DB::connection('business_mirror');