fix: 修复抖音账户账单筛选控件 #156

Merged
qiuxiang merged 1 commits from fix/qiuxiang-douyin-account-filter-controls into main 2026-09-11 10:34:00 +08:00
7 changed files with 151 additions and 10 deletions

View File

@ -136,6 +136,29 @@ class AccountBills extends Controller
return $this->exportQuery($request, $query, $bill_type);
}
public function filterOptions(string $bill_type)
{
$this->authorizeStoreReceivingBills();
abort_unless($bill_type === 'douyin', 404, '该账户类型没有可用的筛选选项。');
try {
return response()->json(
app(StoreReceivingDouyinBillMirrorRepository::class)->filterOptions()
);
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
throw $exception;
}
return response()->json([
'message' => '抖音账户账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'shops' => [],
'bill_items' => [],
], 503);
}
}
private function exportQuery(Request $request, $query, string $billType)
{
$definition = AccountBillColumns::for($billType);

View File

@ -138,6 +138,54 @@ class StoreReceivingAlipayBillMirrorRepository
return $this->exportBatches($query, $sortColumn, $direction, $shopNames, $mapping);
}
/** @return array{shops:array<int,string>,bill_items:array<int,string>} */
public function filterOptionsForChannel(string $channel): array
{
$dataset = $this->datasets->get(self::DATASET);
if (! $this->mirror->ready($dataset)
|| ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) {
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
}
$channelQuery = $this->billQuery();
$this->applyChannel($channelQuery, $channel);
$shopIds = (clone $channelQuery)
->whereNotNull('b.shop_id')
->distinct()
->pluck('b.shop_id')
->filter(static fn (mixed $value): bool => $value !== null && $value !== '')
->all();
$shops = $shopIds === []
? collect()
: $this->mirror->connection()->table('ops_jst_shop_info')
->whereIn('shop_id', $shopIds)
->whereNotNull('shop_name')
->orderBy('shop_name')
->pluck('shop_name');
$billItems = (clone $channelQuery)
->whereNotNull('b.fee_name')
->where('b.fee_name', '<>', '')
->select('b.fee_name')
->distinct()
->orderBy('b.fee_name')
->pluck('b.fee_name');
$normalize = static fn ($values): array => collect($values)
->map(static fn (mixed $value): string => trim((string) $value))
->filter()
->unique()
->values()
->all();
return [
'shops' => $normalize($shops),
'bill_items' => $normalize($billItems),
];
}
/** Read bounded pages so a full export does not sort/buffer millions of rows at once. */
private function exportBatches(Builder $query, string $sortColumn, string $direction, array $shopNames, string $mapping): iterable
{

View File

@ -37,4 +37,10 @@ class StoreReceivingDouyinBillMirrorRepository
'douyin',
);
}
/** @return array{shops:array<int,string>,bill_items:array<int,string>} */
public function filterOptions(): array
{
return $this->source->filterOptionsForChannel(self::SOURCE_CHANNEL);
}
}

View File

@ -4,11 +4,24 @@
<div v-for="stat in stats" :key="stat.label" class="tw-stat"><div class="tw-label">{{ stat.label }}</div><div class="tw-value" :class="stat.className">{{ stat.value }}</div><div class="tw-note">{{ stat.note }}</div></div>
</div>
<form class="tw-filter" @submit.prevent="applyFilters">
<label>店铺<input v-model.trim="filters.store_name" placeholder="店铺名称" /></label>
<label>账单项目<input v-model.trim="filters.bill_item" placeholder="账单项目" /></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>
<label>店铺
<select v-if="isDouyin && !filterOptionsError" v-model="filters.store_name" aria-label="店铺筛选" :aria-busy="filterOptionsLoading ? 'true' : 'false'" @change="applyFilters">
<option value="">{{ filterOptionsLoading ? '店铺加载中…' : '全部店铺' }}</option>
<option v-for="shop in filterOptions.shops" :key="shop" :value="shop">{{ shop }}</option>
</select>
<input v-else v-model.trim="filters.store_name" placeholder="店铺名称" />
</label>
<label>账单项目
<select v-if="isDouyin && !filterOptionsError" v-model="filters.bill_item" aria-label="账单项目筛选" :aria-busy="filterOptionsLoading ? 'true' : 'false'" @change="applyFilters">
<option value="">{{ filterOptionsLoading ? '账单项目加载中…' : '全部账单项目' }}</option>
<option v-for="item in filterOptions.bill_items" :key="item" :value="item">{{ item }}</option>
</select>
<input v-else v-model.trim="filters.bill_item" placeholder="账单项目" />
</label>
<label class="tw-date">发生时间<span><input v-model="filters.start" type="date" aria-label="开始日期" :max="filters.end || undefined" :aria-invalid="invalidDateRange ? 'true' : 'false'" @change="applyFilters" /><b>至</b><input v-model="filters.end" type="date" aria-label="结束日期" :min="filters.start || undefined" :aria-invalid="invalidDateRange ? 'true' : 'false'" @change="applyFilters" /></span></label>
<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">{{ loading ? '查询中…' : '查询' }}</button><button class="tw-button" type="button" @click="resetFilters">重置</button></div>
<p v-if="invalidDateRange" class="tw-filter-error" role="alert">结束日期不能早于开始日期</p>
</form>
<section class="tw-panel" :aria-label="`${columnsTitle || '账户'}账单明细`">
<div v-if="loadError" class="tw-alert" role="alert"><span>账单加载失败,请重试</span><button type="button" @click="fetch">重新加载</button></div>
@ -43,18 +56,21 @@ import TableColumnSettings from './TableColumnSettings.vue';
const SORTABLE = ['occurred_at','income','expense','balance','record_code'];
export default {
name:'AccountBills', components:{ TableColumnSettings }, props:{ apiBase:{type:String,required:true}, billType:{type:String,required:true}, userKey:{type:[String,Number],required:true} },
data(){return{loading:false,loadError:false,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:{store_name:'',bill_item:'',start:'',end:'',keyword:''},sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
data(){return{loading:false,loadError:false,rows:[],sourceColumns:[],configuredColumns:[],columnsTitle:'',sourceGroups:[],summary:{income:0,expense:0,balance:0,count:0},total:0,currentPage:1,lastPage:1,perPage:20,filters:{store_name:'',bill_item:'',start:'',end:'',keyword:''},filterOptions:{shops:[],bill_items:[]},filterOptionsLoading:false,filterOptionsError:false,requestSerial:0,sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
computed:{
isDouyin(){return this.billType==='douyin';},
invalidDateRange(){return Boolean(this.filters.start&&this.filters.end&&this.filters.start>this.filters.end);},
visibleColumns(){return this.configuredColumns.filter(column=>column.visible);},
visibleGroups(){return this.visibleColumns.reduce((groups,column)=>{if(!column.group)return groups;const last=groups[groups.length-1];if(last&&last.label===column.group)last.span+=1;else groups.push({label:column.group,span:1});return groups;},[]);},
stats(){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);}
},
mounted(){this.fetch();},
mounted(){if(this.isDouyin)this.fetchFilterOptions();this.fetch();},
methods:{
async fetch(){this.loading=true;this.loadError=false;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};if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){this.rows=[];this.total=0;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{this.loading=false;}},
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(){const requestId=++this.requestSerial;this.loading=true;this.loadError=false;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}});if(requestId!==this.requestSerial)return;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};if(body.columns){this.columnsTitle=body.columns.title||'';this.sourceGroups=body.columns.groups||[];this.sourceColumns=this.attachGroups(body.columns.columns||body.columns||[],this.sourceGroups);}}catch(error){if(requestId!==this.requestSerial)return;this.rows=[];this.total=0;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{if(requestId===this.requestSerial)this.loading=false;}},
attachGroups(columns,groups){let index=0;return columns.map((column,columnIndex)=>{while(groups[index]&&columnIndex>=groups.slice(0,index+1).reduce((sum,group)=>sum+Number(group.span||0),0))index+=1;return{...column,key:column.field||`column-${columnIndex}`,group:groups[index]?groups[index].label:''};});},
applyFilters(){this.currentPage=1;this.fetch();},resetFilters(){this.filters={store_name:'',bill_item:'',start:'',end:'',keyword:''};this.applyFilters();},changePage(page){this.currentPage=page;this.fetch();this.$nextTick(()=>{if(this.$refs.scroll)this.$refs.scroll.scrollTop=0;});},
applyFilters(){if(this.invalidDateRange)return;this.currentPage=1;this.fetch();},resetFilters(){this.filters={store_name:'',bill_item:'',start:'',end:'',keyword:''};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'?'↑':'↓'):'↕';},
getValue(row,field){if(!field)return'';if(field.indexOf('payload.')===0){const payload=row.payload||{};const value=payload[field.slice(8)];return value===null||value===undefined?'':value;}const value=row[field];return value===null||value===undefined?'':value;},rawValue(row,column){return this.getValue(row,column.field);},
displayValue(row,column){const value=this.rawValue(row,column);if(value==='')return'—';if(column.kind==='money_in')return Number(value)>0?`+${this.money(value)}`:'—';if(column.kind==='money_out')return Number(value)>0?`-${this.money(value)}`:'—';if(['balance','money'].indexOf(column.kind)!==-1)return this.money(value);return value;},cellClass(column){return{'is-number':this.isNumber(column.kind),'is-id':column.kind==='num','is-income':column.kind==='money_in','is-expense':column.kind==='money_out'};},isNumber(kind){return['money','money_in','money_out','balance','num'].indexOf(kind)!==-1;},money(value){return`¥${Number(value||0).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}`;},
@ -65,7 +81,7 @@ export default {
</script>
<style scoped>
.table-workspace{padding:1rem;color:#171717;font:400 .8125rem/1.5 "JetBrains Mono","Microsoft YaHei","PingFang SC",monospace}.tw-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-bottom:.75rem}.tw-stat{padding:.875rem 1rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-label,.tw-note,.tw-count{color:#595959;font-size:.75rem}.tw-value{margin-top:.375rem;font-size:1.375rem;font-weight:700;font-variant-numeric:tabular-nums}.tw-note{margin-top:.1875rem}.is-income{color:#53793e}.is-expense{color:#b80000}.tw-filter{display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem;padding:.875rem 1rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-filter>label{display:flex;flex-direction:column;gap:.25rem;color:#595959;font-size:.75rem;font-weight:500}.tw-filter input,.tw-tools select{height:2.125rem;min-width:10rem;padding:0 .625rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:400 .8125rem/1 inherit}.tw-filter input:focus,.tw-tools select:focus{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2);outline:none}.tw-filter .tw-keyword{flex:1;min-width:14rem}.tw-keyword input{width:100%}.tw-date span,.tw-actions,.tw-tools,.tw-page-actions{display:flex;align-items:center;gap:.5rem}.tw-date b{font-weight:400}.tw-date input{min-width:8.5rem}.tw-actions{align-self:flex-end;margin-left:auto}.tw-button,.tw-page-actions button{display:inline-flex;align-items:center;justify-content:center;gap:.375rem;height:2.125rem;padding:0 .875rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:500 .8125rem/1 inherit;cursor:pointer}.tw-button:hover{border-color:#856632;color:#856632}.tw-primary{border-color:#856632;background:#856632;color:#fff}.tw-primary:hover{background:#6a5129;color:#fff}.tw-button .material-icons-outlined{font-size:1.125rem}.tw-panel{overflow:visible;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-alert{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin:.75rem;padding:.75rem;border:1px solid #b80000;border-radius:.5rem;background:#fff4f4;color:#b80000}.tw-alert button{border:0;background:transparent;color:#b80000;font:inherit;font-weight:600;cursor:pointer}.tw-toolbar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-bottom:1px solid #d8d2c7}.tw-toolbar strong{margin-right:.625rem;font-size:.875rem}.tw-tools{flex-wrap:wrap;justify-content:flex-end}.tw-tools>select{min-width:13.75rem}.tw-scroll{position:relative;max-height:min(62vh,42rem);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.tw-table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;font-size:.78125rem}.tw-table th,.tw-table td{min-width:7.5rem;padding:.5rem .625rem;border-right:1px solid #eeeae2;border-bottom:1px solid #eeeae2;white-space:nowrap}.tw-table thead th{position:sticky;z-index:4;background:#f7f0de;color:#171717;font-weight:600}.tw-groups th{top:0;height:2rem;text-align:center;color:#856632;font-size:.75rem}.tw-columns th{top:2rem;height:2.25rem;text-align:left;background:#fbfaf7;box-shadow:inset 0 -1px #d8d2c7}.tw-columns.is-single th{top:0}.tw-columns th.is-sortable{cursor:pointer}.tw-columns th.is-sortable:hover,.tw-sort{color:#856632}.tw-table tbody tr:hover{background:#f7f0de}.tw-table .is-number{text-align:right;font-variant-numeric:tabular-nums}.tw-table .is-id{color:#595959}.tw-tag{display:inline-block;padding:.0625rem .375rem;border-radius:.25rem;background:#f7f0de;color:#856632}.tw-empty{padding:3rem 1rem!important;text-align:center!important;color:#595959}.tw-pager{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-top:1px solid #d8d2c7;color:#595959}.tw-page-actions select{height:1.875rem;border:1px solid #d8d2c7;border-radius:.375rem}.tw-page-actions button{width:1.875rem;height:1.875rem;padding:0}.tw-page-actions button:disabled{cursor:not-allowed;opacity:.45}@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{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}}
.table-workspace{padding:1rem;color:#171717;font:400 .8125rem/1.5 "JetBrains Mono","Microsoft YaHei","PingFang SC",monospace}.tw-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-bottom:.75rem}.tw-stat{padding:.875rem 1rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-label,.tw-note,.tw-count{color:#595959;font-size:.75rem}.tw-value{margin-top:.375rem;font-size:1.375rem;font-weight:700;font-variant-numeric:tabular-nums}.tw-note{margin-top:.1875rem}.is-income{color:#53793e}.is-expense{color:#b80000}.tw-filter{display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem;padding:.875rem 1rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-filter>label{display:flex;flex-direction:column;gap:.25rem;color:#595959;font-size:.75rem;font-weight:500}.tw-filter input,.tw-filter select,.tw-tools select{height:2.125rem;min-width:10rem;padding:0 .625rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:400 .8125rem/1 inherit}.tw-filter input:focus,.tw-filter select:focus,.tw-tools select:focus{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2);outline:none}.tw-filter .tw-keyword{flex:1;min-width:14rem}.tw-keyword input{width:100%}.tw-date span,.tw-actions,.tw-tools,.tw-page-actions{display:flex;align-items:center;gap:.5rem}.tw-date b{font-weight:400}.tw-date input{min-width:8.5rem}.tw-filter-error{grid-column:1/-1;margin:0;color:#b80000;font-size:.75rem}.tw-actions{align-self:flex-end;margin-left:auto}.tw-button,.tw-page-actions button{display:inline-flex;align-items:center;justify-content:center;gap:.375rem;height:2.125rem;padding:0 .875rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:500 .8125rem/1 inherit;cursor:pointer}.tw-button:hover{border-color:#856632;color:#856632}.tw-button:disabled{cursor:not-allowed;opacity:.55}.tw-primary{border-color:#856632;background:#856632;color:#fff}.tw-primary:hover{background:#6a5129;color:#fff}.tw-button .material-icons-outlined{font-size:1.125rem}.tw-panel{overflow:visible;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-alert{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin:.75rem;padding:.75rem;border:1px solid #b80000;border-radius:.5rem;background:#fff4f4;color:#b80000}.tw-alert button{border:0;background:transparent;color:#b80000;font:inherit;font-weight:600;cursor:pointer}.tw-toolbar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-bottom:1px solid #d8d2c7}.tw-toolbar strong{margin-right:.625rem;font-size:.875rem}.tw-tools{flex-wrap:wrap;justify-content:flex-end}.tw-tools>select{min-width:13.75rem}.tw-scroll{position:relative;max-height:min(62vh,42rem);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.tw-table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;font-size:.78125rem}.tw-table th,.tw-table td{min-width:7.5rem;padding:.5rem .625rem;border-right:1px solid #eeeae2;border-bottom:1px solid #eeeae2;white-space:nowrap}.tw-table thead th{position:sticky;z-index:4;background:#f7f0de;color:#171717;font-weight:600}.tw-groups th{top:0;height:2rem;text-align:center;color:#856632;font-size:.75rem}.tw-columns th{top:2rem;height:2.25rem;text-align:left;background:#fbfaf7;box-shadow:inset 0 -1px #d8d2c7}.tw-columns.is-single th{top:0}.tw-columns th.is-sortable{cursor:pointer}.tw-columns th.is-sortable:hover,.tw-sort{color:#856632}.tw-table tbody tr:hover{background:#f7f0de}.tw-table .is-number{text-align:right;font-variant-numeric:tabular-nums}.tw-table .is-id{color:#595959}.tw-tag{display:inline-block;padding:.0625rem .375rem;border-radius:.25rem;background:#f7f0de;color:#856632}.tw-empty{padding:3rem 1rem!important;text-align:center!important;color:#595959}.tw-pager{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-top:1px solid #d8d2c7;color:#595959}.tw-page-actions select{height:1.875rem;border:1px solid #d8d2c7;border-radius:.375rem}.tw-page-actions button{width:1.875rem;height:1.875rem;padding:0}.tw-page-actions button:disabled{cursor:not-allowed;opacity:.45}@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{display:grid;grid-template-columns:minmax(10rem,1fr) minmax(10rem,1fr) auto minmax(14rem,2fr) auto;align-items:end}.tw-filter>label{min-width:0}.tw-actions{align-self:auto;justify-self:end;margin-left:0}@media(max-width:900px){.tw-filter{grid-template-columns:1fr}.tw-actions{justify-self:start}}

View File

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

View File

@ -194,6 +194,29 @@ class PagesTest extends FeatureTestCase
->assertJsonPath('total', 0);
}
public function testDouyinAccountBillsFilterOptionsEndpointReturnsUsableChoices(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
$repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class);
$repository->shouldReceive('filterOptions')->once()->andReturn([
'shops' => ['放心购旗舰店', '放心购专营店'],
'bill_items' => ['订单收入', '交易佣金'],
]);
$this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository);
$this->loginAs()
->withHeader('X-Requested-With', 'XMLHttpRequest')
->get(route('ecommerce-finance.account-bills.filter-options', ['bill_type' => 'douyin']))
->assertOk()
->assertExactJson([
'shops' => ['放心购旗舰店', '放心购专营店'],
'bill_items' => ['订单收入', '交易佣金'],
]);
}
public function testDouyinAccountBillsExportUsesSelectedColumnOrder(): void
{
$this->user->attachPermission(

View File

@ -342,6 +342,28 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
$db->disableQueryLog();
}
public function test_douyin_filter_options_only_include_the_fixed_channel(): void
{
$this->markReady();
$db = DB::connection('business_mirror');
$db->table('ops_jst_shop_info')->insert([
['Id' => 10, 'shop_id' => 7, 'shop_name' => '放心购A店'],
['Id' => 11, 'shop_id' => 8, 'shop_name' => '放心购B店'],
['Id' => 12, 'shop_id' => 9, 'shop_name' => '淘系店铺'],
]);
$db->table('ops_jst_original_bill_info')->insert([
$this->row(1, 7, '2026-08-01 09:00:00', '1', 10, 'TouTiaoFXGZJ', '交易佣金'),
$this->row(2, 8, '2026-08-02 09:00:00', '1', 20, 'TouTiaoFXGZJ', '订单收入'),
$this->row(3, 7, '2026-08-03 09:00:00', '1', 30, 'TouTiaoFXGZJ', '交易佣金'),
$this->row(4, 9, '2026-08-04 09:00:00', '1', 40, 'CommonBillTGCPay', '淘系费用'),
]);
$options = (new StoreReceivingDouyinBillMirrorRepository($this->repository))->filterOptions();
$this->assertSame(['放心购A店', '放心购B店'], $options['shops']);
$this->assertSame(['交易佣金', '订单收入'], $options['bill_items']);
}
private function markReady(): void
{
foreach ([StoreReceivingAlipayBillMirrorRepository::DATASET, StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET] as $dataset) {
@ -361,11 +383,12 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
string $direction,
float $amount,
string $channel = 'CommonBillTGCPay',
string $feeName = '交易佣金',
): array
{
return [
'Id' => $id, 'shop_id' => $shopId, 'channel' => $channel,
'primary_key' => 'KEY-' . $id, 'fee_name' => '交易佣金', 'inout_type' => $direction,
'primary_key' => 'KEY-' . $id, 'fee_name' => $feeName, 'inout_type' => $direction,
'p_auto_id' => 'P-' . $id, 'shop_i_id' => 'STYLE-' . $id,
'shop_sku_id' => 'SHOP-SKU-' . $id, 'sku_id' => 'SKU-' . $id,
'so_id' => 'ORDER-' . $id, 'combine_type' => 'double',