统一淘系微信和支付宝账单筛选交互 #267

Merged
yueyang merged 1 commits from feature/yueyang-alipay-wechat-filters into main 2026-09-24 15:15:10 +08:00
7 changed files with 164 additions and 10 deletions

View File

@ -85,6 +85,27 @@ class AlipayBills extends Controller
}
}
public function filterOptions(StoreReceivingAlipayBillMirrorRepository $repository)
{
$this->authorizeStoreReceivingBills();
try {
return response()->json($repository->filterOptionsForChannel(
'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true
));
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
throw $exception;
}
return response()->json([
'message' => '淘系支付宝账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'shops' => [],
'bill_items' => [],
], 503);
}
}
public function export(Request $request, StoreReceivingAlipayBillMirrorRepository $repository)
{
$this->authorizeStoreReceivingBills();

View File

@ -182,7 +182,7 @@ class StoreReceivingAlipayBillMirrorRepository
}
/** @return array{shops:array<int,string>,bill_items:array<int,string>} */
public function filterOptionsForChannel(string|array $channel, string $datasetKey = self::DATASET): array
public function filterOptionsForChannel(string|array $channel, string $datasetKey = self::DATASET, bool $excludeNonTaobaoPlatforms = false): array
{
$dataset = $this->datasets->get($datasetKey);
if (! $this->mirror->ready($dataset)
@ -192,6 +192,9 @@ class StoreReceivingAlipayBillMirrorRepository
$channelQuery = $this->billQuery($dataset->targetTable());
$this->applyChannel($channelQuery, $channel, $dataset->fixedFilters());
if ($excludeNonTaobaoPlatforms) {
$this->applyTaobaoPlatformScope($channelQuery);
}
$shopIds = (clone $channelQuery)
->whereNotNull('b.shop_id')

View File

@ -84,7 +84,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','jd-transactions','jd-wallet','vipshop'].indexOf(this.billType)!==-1;},
usesMirrorOptions(){return ['douyin','pinduoduo','dewu','taobao-wechat','jd-transactions','jd-wallet','vipshop'].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};},

View File

@ -9,13 +9,40 @@
</div>
<form class="tw-filter" @submit.prevent="applyFilters">
<label>原始店铺<input v-model.trim="filters.store_name" placeholder="如 天猫-亿家达旗舰店" /></label>
<label>原始店铺
<el-select v-if="!filterOptionsError" v-model="filters.store_name" filterable clearable :loading="filterOptionsLoading" placeholder="全部店铺" aria-label="店铺筛选" popper-class="alipay-bill-filter-options" no-match-text="没有匹配的店铺" no-data-text="暂无店铺" @change="applyFilters">
<el-option v-for="shop in filterOptions.shops" :key="shop" :label="shop" :value="shop" />
</el-select>
<input v-else v-model.trim="filters.store_name" placeholder="店铺名称" />
</label>
<label>数据源渠道<input v-model.trim="filters.source_channel" placeholder="如 alipayCSV" /></label>
<label>账单项目<input v-model.trim="filters.bill_item" placeholder="如 交易佣金" /></label>
<label>账单项目
<el-select v-if="!filterOptionsError" v-model="filters.bill_item" filterable clearable :loading="filterOptionsLoading" placeholder="全部账单项目" aria-label="账单项目筛选" popper-class="alipay-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>
<input v-else v-model.trim="filters.bill_item" placeholder="账单项目" />
</label>
<label>收支类型<select v-model="filters.business_type"><option value="">全部类型</option><option>收入</option><option>支出</option></select></label>
<label class="tw-date">发生时间<span><input v-model="filters.start" type="date" aria-label="开始日期" /><b>至</b><input v-model="filters.end" type="date" aria-label="结束日期" /></span></label>
<div class="tw-filter-field tw-date">
<span class="tw-filter-label">发生时间</span>
<div class="tw-date-row">
<span class="tw-date-control has-picker" @click="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>
</span>
<b>至</b>
<span class="tw-date-control has-picker" @click="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>
</span>
</div>
</div>
<label class="tw-keyword">关键词(费用 / 订单 / 商品编码)<input v-model.trim="filters.keyword" placeholder="输入费用名称、订单号或商品编码" /></label>
<div class="tw-actions"><button class="tw-button tw-primary" type="submit">查询</button><button class="tw-button" type="button" @click="resetFilters">重置</button></div>
<div class="tw-actions"><button class="tw-button tw-primary" type="submit" :disabled="invalidDateRange">查询</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>
</form>
<section class="tw-panel" aria-label="淘系支付宝账户账单明细">
@ -68,6 +95,12 @@
<script>
import TableColumnSettings from './TableColumnSettings.vue';
import { Select, Option } from 'element-ui';
import flatPicker from 'vue-flatpickr-component';
import { Mandarin } from 'flatpickr/dist/l10n/zh';
import 'flatpickr/dist/flatpickr.css';
const dateConfig = { dateFormat: 'Y-m-d', locale: Mandarin, disableMobile: true, allowInput: false, onReady: (_, value, picker) => picker.calendarContainer.classList.add('alipay-bill-calendar') };
const COLUMNS = [
['记录编码', 'record_code', 'num', '基础信息'], ['原始店铺', 'store_name', 'text', '基础信息'], ['自定义备注', 'custom_remark', 'text', '基础信息'], ['账单项目', 'bill_item', 'tag', '基础信息'],
@ -79,19 +112,25 @@ const COLUMNS = [
const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
export default {
name: 'TaobaoAlipayBills', components: { TableColumnSettings },
name: 'TaobaoAlipayBills', components: { TableColumnSettings, flatPicker, [Select.name]: Select, [Option.name]: Option },
props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true }, userKey: { type: [String, Number], required: true } },
data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; },
data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, filterOptions: { shops: [], bill_items: [] }, filterOptionsLoading: false, filterOptionsError: false, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; },
computed: {
defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); },
visibleGroups() { return this.visibleColumns.reduce((groups, column) => { 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() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支差额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; },
rangeStart() { return this.total === 0 ? 0 : (this.currentPage - 1) * this.perPage + 1; }, rangeEnd() { return Math.min(this.currentPage * this.perPage, this.total); },
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 }; },
},
mounted() { this.fetch(); },
mounted() { this.fetchFilterOptions(); this.fetch(); },
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 : [] }; } catch (error) { this.filterOptionsError = true; } finally { this.filterOptionsLoading = false; } },
async fetch() { this.loading = true; this.error = ''; try { const response = await window.axios.get(this.apiBase, { params: { ...this.filters, sort: this.sort.key, dir: this.sort.dir, limit: this.perPage, page: this.currentPage } }); const body = response.data || {}; 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 }; } catch (error) { this.rows = []; this.total = 0; this.summary = { income: 0, expense: 0, balance: 0, count: 0 }; this.error = (error.response && error.response.data && error.response.data.message) || '淘系支付宝账单暂时无法读取,请稍后重试。'; } finally { this.loading = false; } },
applyFilters() { this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); },
applyFilters() { if (this.invalidDateRange) return; this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); },
openDatePicker(refName) { const picker = this.$refs[refName]; if (picker && picker.fp) picker.fp.open(); },
changeDate(field, value) { const date = value || ''; if (this.filters[field] === date) return; this.filters[field] = date; this.applyFilters(); },
changePage(page) { this.currentPage = page; this.fetch(); this.$nextTick(() => { if (this.$refs.scroll) this.$refs.scroll.scrollTop = 0; }); },
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' ? '↑' : '↓') : '↕'; },
rawValue(row, column) { const value = row[column.field]; return value === null || value === undefined ? '' : value; },
@ -115,4 +154,38 @@ export default {
@media(max-width:900px){.table-workspace{padding:.75rem}.tw-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.tw-filter>label,.tw-filter .tw-keyword{width:100%}.tw-filter input,.tw-filter select{width:100%}.tw-date input{min-width:0}.tw-actions{margin-left:0}.tw-toolbar,.tw-pager{align-items:flex-start;flex-direction:column}.tw-tools{justify-content:flex-start}.tw-scroll{max-height:58vh;width:100%}}@media(max-width:520px){.tw-stats{grid-template-columns:1fr}.tw-date span{align-items:stretch;flex-direction:column}.tw-tools>select{min-width:0;max-width:100%}.tw-page-actions{flex-wrap:wrap}}
.tw-toolbar { flex-wrap: wrap; }
.tw-tools { flex: 1 1 24rem; min-width: 0; }
.tw-filter { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.tw-filter .tw-date { grid-column: span 2; }
.tw-filter ::v-deep .el-select { width: 100%; min-width: 0; }
.tw-filter ::v-deep .el-select .el-input__inner { height: 34px; min-width: 0; padding-right: 30px; border-color: #d8d2c7; border-radius: 8px; color: #171717; font: inherit; }
.tw-filter ::v-deep .el-select .el-input.is-focus .el-input__inner,
.tw-filter ::v-deep .el-select .el-input__inner:focus { border-color: #856632; }
.tw-filter ::v-deep .el-input__icon { line-height: 34px; }
.tw-filter-field { display: flex; flex-direction: column; gap: .25rem; min-width: 0; color: #595959; font-size: .75rem; font-weight: 500; }
.tw-date-row { display: flex; align-items: center; gap: .5rem; }
.tw-date-control.has-picker { position: relative; display: flex; flex-direction: row; width: 14rem; max-width: 100%; height: 2.125rem; overflow: hidden; border: 1px solid #d8d2c7; border-radius: .5rem; background: #fff; cursor: pointer; }
.tw-date-control.has-picker:focus-within { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.2); }
.tw-date-control.has-picker ::v-deep input { width: 100%; min-width: 0; height: 100%; padding: 0 4rem 0 .625rem; border: 0; border-radius: 0; background: transparent; box-shadow: none; cursor: pointer; }
.tw-date-control .tw-date-picker,
.tw-date-control .tw-date-clear { position: absolute; top: 0; height: 2rem; border-top: 0; border-bottom: 0; }
.tw-date-control .tw-date-picker { right: 0; border-right: 0; border-radius: 0; }
.tw-date-control .tw-date-clear { right: 2.125rem; z-index: 1; display: flex; align-items: center; justify-content: center; width: 1.5rem; padding: 0; border-left: 0; border-right: 0; background: #fff; color: #595959; cursor: pointer; }
.tw-date-control .tw-date-clear .material-icons-outlined { font-size: 1rem; }
.tw-filter-error { grid-column: 1/-1; margin: 0; color: #b80000; font-size: .75rem; }
@media(max-width:1100px) { .tw-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); } .tw-filter .tw-date { grid-column: 1 / -1; } }
@media(max-width:600px) { .tw-filter { grid-template-columns: minmax(0, 1fr); } .tw-filter .tw-date { grid-column: auto; } .tw-date-row { align-items: stretch; flex-direction: column; } .tw-date-control.has-picker { width: 100%; } }
</style>
<style>
.alipay-bill-calendar { font-family: "Microsoft YaHei", sans-serif; border: 1px solid #d8d2c7; border-radius: 8px; background: #fff; }
.alipay-bill-calendar .flatpickr-day.selected,
.alipay-bill-calendar .flatpickr-day.selected:hover { background: #856632; border-color: #856632; }
.alipay-bill-calendar .flatpickr-day.today { border-color: #856632; }
.alipay-bill-calendar .flatpickr-day:hover,
.alipay-bill-calendar .flatpickr-day:focus { background: #f7f0de; border-color: #d8d2c7; }
.alipay-bill-filter-options { max-width: calc(100vw - 32px); }
.alipay-bill-filter-options .el-select-dropdown__item { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.alipay-bill-filter-options .el-select-dropdown__item.selected { color: #856632; }
.alipay-bill-filter-options .el-select-dropdown__item.hover,
.alipay-bill-filter-options .el-select-dropdown__item:hover { background: #f7f0de; }
</style>

View File

@ -28,6 +28,8 @@ Route::get('ecommerce-finance/alipay-bills', [AlipayBills::class, 'index'])
->name('ecommerce-finance.alipay-bills.index');
Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export'])
->name('ecommerce-finance.alipay-bills.export');
Route::get('ecommerce-finance/alipay-bills/filter-options', [AlipayBills::class, 'filterOptions'])
->name('ecommerce-finance.alipay-bills.filter-options');
Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show'])
->name('ecommerce-finance.alipay-bills.show');

View File

@ -225,6 +225,38 @@ class PagesTest extends FeatureTestCase
->assertJsonPath('data.0.occurred_at', '2026-07-15 23:59:12');
}
public function testTaobaoAlipayFilterOptionsUseTheSameMirrorScope(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class);
$repository->shouldReceive('filterOptionsForChannel')->once()->with(
'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true
)->andReturn(['shops' => ['天猫A店'], 'bill_items' => ['交易收款']]);
$this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository);
$this->loginAs()
->getJson(route('ecommerce-finance.alipay-bills.filter-options'))
->assertOk()
->assertExactJson(['shops' => ['天猫A店'], 'bill_items' => ['交易收款']]);
}
public function testTaobaoAlipayFilterOptionsReportAnUnreadyMirror(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class);
$repository->shouldReceive('filterOptionsForChannel')->once()
->andThrow(new \RuntimeException('store_receiving_alipay_bill_mirror_not_ready'));
$this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository);
$this->loginAs()
->getJson(route('ecommerce-finance.alipay-bills.filter-options'))
->assertStatus(503)
->assertJsonPath('shops', [])
->assertJsonPath('bill_items', []);
}
public function testTaobaoAlipayBillsPageProvidesTheVueMountPoint()
{
$this->user->attachPermission(

View File

@ -467,6 +467,29 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
$this->assertSame(['交易佣金', '订单收入'], $options['bill_items']);
}
public function test_alipay_filter_options_match_the_visible_taobao_channel_and_platform_scope(): void
{
$this->markReady();
$db = DB::connection('business_mirror');
$db->table('ops_jst_shop_info')->insert([
['Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫A店', 'shop_site' => '淘宝天猫'],
['Id' => 11, 'shop_id' => 8, 'shop_name' => '拼多多B店', 'shop_site' => '拼多多'],
['Id' => 12, 'shop_id' => 9, 'shop_name' => '天猫C店', 'shop_site' => '淘宝天猫'],
]);
$db->table('ops_jst_original_bill_info')->insert([
$this->row(1, 7, '2026-08-01 09:00:00', '1', 10, 'alipayCSV', '交易收款'),
$this->row(2, 8, '2026-08-01 10:00:00', '1', 20, 'alipayCSV', '其他平台费用'),
$this->row(3, 9, '2026-08-01 11:00:00', '1', 30, 'OtherChannel', '其他渠道费用'),
]);
$options = $this->repository->filterOptionsForChannel(
'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true
);
$this->assertSame(['天猫A店'], $options['shops']);
$this->assertSame(['交易收款'], $options['bill_items']);
}
public function test_shared_dataset_keeps_channel_predicates_for_all_query_entry_points(): void
{
$this->markReady();