diff --git a/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php b/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php index 29724538..1dd791ed 100644 --- a/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/OrderDataMirrorRepository.php @@ -83,6 +83,82 @@ class OrderDataMirrorRepository return $this->normalizeShopNames($rows); } + /** + * 商品推广费用明细的店铺候选:指定统计日期出现过的店铺。 + * + * 推广表的店铺名带下划线("天猫_卓禾家具旗舰店"),和订单镜像的"天猫-…"不是同一套 + * 命名,不能复用订单店铺候选;按日期过滤后有 400 行左右,结果缓存 24 小时。 + * + * @return array + */ + 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 + */ + 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 !== []) { // 多店铺:每个店铺做一个模糊匹配再取并集,选中的是完整店铺名时等同于精确匹配。 diff --git a/modules/EcommerceFinance/Repositories/OrderDataRepository.php b/modules/EcommerceFinance/Repositories/OrderDataRepository.php index 0d3e9abb..4e4e73c0 100644 --- a/modules/EcommerceFinance/Repositories/OrderDataRepository.php +++ b/modules/EcommerceFinance/Repositories/OrderDataRepository.php @@ -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'] ?? '') !== '') { diff --git a/modules/EcommerceFinance/View/OrderDataPageData.php b/modules/EcommerceFinance/View/OrderDataPageData.php index 43b2f327..980d797c 100644 --- a/modules/EcommerceFinance/View/OrderDataPageData.php +++ b/modules/EcommerceFinance/View/OrderDataPageData.php @@ -116,6 +116,24 @@ class OrderDataPageData return array_keys($names); } + /** + * 商品推广费用明细的店铺候选:该统计日期出现过的店铺。 + * + * @return array + */ + 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, ]; diff --git a/resources/views/business/pages/order-data/after-sales-expenses.blade.php b/resources/views/business/pages/order-data/after-sales-expenses.blade.php index e330161f..e07ed5ea 100644 --- a/resources/views/business/pages/order-data/after-sales-expenses.blade.php +++ b/resources/views/business/pages/order-data/after-sales-expenses.blade.php @@ -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; }
@@ -365,27 +216,10 @@ -
- -
- - - -
-
+ @include('business.pages.partials.order-data-shop-picker', [ + 'shopPickerOptions' => $orderData['orderDataShopOptions'] ?? [], + 'shopPickerSelected' => $filters['shop_names'] ?? [], + ])
@@ -646,160 +480,3 @@ })(); - diff --git a/resources/views/business/pages/order-data/promotion-expenses.blade.php b/resources/views/business/pages/order-data/promotion-expenses.blade.php index 27a539a2..728313c1 100644 --- a/resources/views/business/pages/order-data/promotion-expenses.blade.php +++ b/resources/views/business/pages/order-data/promotion-expenses.blade.php @@ -23,10 +23,11 @@
-
- - -
+ @include('business.pages.partials.order-data-shop-picker', [ + 'shopPickerOptions' => $orderData['orderDataShopOptions'] ?? [], + 'shopPickerSelected' => $filters['shop_names'] ?? [], + 'shopPickerToggleId' => 'promotion-expense-shop', + ])
diff --git a/resources/views/business/pages/partials/order-data-shop-picker.blade.php b/resources/views/business/pages/partials/order-data-shop-picker.blade.php new file mode 100644 index 00000000..1b8bb57b --- /dev/null +++ b/resources/views/business/pages/partials/order-data-shop-picker.blade.php @@ -0,0 +1,345 @@ +{{-- + 店铺名称:可点选的多选下拉,支持在候选里模糊搜索,提交为 shop_name[](多个取并集)。 + 订单售后费用明细和商品推广费用明细共用这套控件。 + + 需要传入: + $shopPickerOptions 候选店铺名(list) + $shopPickerSelected 已选店铺名(list) +--}} +@php + $shopPickerOptions = array_values($shopPickerOptions ?? []); + $shopPickerSelected = array_values($shopPickerSelected ?? []); + $shopPickerToggleId = $shopPickerToggleId ?? 'order-data-shop-toggle'; +@endphp + + + +
+ +
+ + + +
+
+ + diff --git a/tests/Feature/Business/OrderDataPagesUpstreamTest.php b/tests/Feature/Business/OrderDataPagesUpstreamTest.php index 615e5da0..42b9eded 100644 --- a/tests/Feature/Business/OrderDataPagesUpstreamTest.php +++ b/tests/Feature/Business/OrderDataPagesUpstreamTest.php @@ -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); diff --git a/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php index bdbc571e..717d7fa2 100644 --- a/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php @@ -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');