商品推广费用明细:店铺筛选改成和订单售后费用明细一致的多选控件
- 把「店铺名称:可点选的多选 + 模糊搜索」抽成公共组件 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:
parent
2a30d9bac3
commit
4b97b30a3a
@ -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 !== []) {
|
||||
// 多店铺:每个店铺做一个模糊匹配再取并集,选中的是完整店铺名时等同于精确匹配。
|
||||
|
||||
@ -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'] ?? '') !== '') {
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
|
||||
@ -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="商品、款式或店铺编码">
|
||||
|
||||
@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user