diff --git a/modules/EcommerceFinance/Http/Controllers/TaobaoWechatAccountBills.php b/modules/EcommerceFinance/Http/Controllers/TaobaoWechatAccountBills.php index 5f4ef9f2..5aaeff2b 100644 --- a/modules/EcommerceFinance/Http/Controllers/TaobaoWechatAccountBills.php +++ b/modules/EcommerceFinance/Http/Controllers/TaobaoWechatAccountBills.php @@ -7,13 +7,18 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Modules\EcommerceFinance\AccountBillColumns; use Modules\EcommerceFinance\Http\Concerns\DecoratesLedgerRemarks; +use Modules\EcommerceFinance\Repositories\TaobaoAlipaySettlementBillRepository; use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository; use RuntimeException; class TaobaoWechatAccountBills extends Controller { use DecoratesLedgerRemarks; - public function index(Request $request, TaobaoWechatSettlementBillRepository $repository) + public function index( + Request $request, + TaobaoWechatSettlementBillRepository $repository, + TaobaoAlipaySettlementBillRepository $alipayRepository, + ) { $this->authorizeStoreReceivingBills(); @@ -50,16 +55,48 @@ class TaobaoWechatAccountBills extends Controller 'current_page' => 1, 'last_page' => 1, 'per_page' => (int) ($validated['limit'] ?? 10), - 'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0], + 'summary' => $this->emptySummary(), 'columns' => AccountBillColumns::for('taobao-wechat'), ], 503); } + $alipaySummary = null; + try { + // 微信账单没有余额列,期初和期末统一取同一筛选条件下淘系支付宝的账户汇总。 + $alipaySummary = $alipayRepository->summary($filters); + } catch (RuntimeException $exception) { + if ($exception->getMessage() !== 'taobao_alipay_authoritative_bills_not_ready') { + throw $exception; + } + } + + $data['summary'] = array_merge($data['summary'], [ + 'opening_balance' => $alipaySummary['opening_balance'] ?? null, + 'opening_balance_date' => $alipaySummary['opening_balance_date'] ?? null, + 'opening_balance_source' => $alipaySummary['opening_balance_source'] ?? null, + 'balance' => $alipaySummary['balance'] ?? null, + 'balance_source' => $alipaySummary === null ? 'unavailable' : 'taobao_alipay', + ]); $data['columns'] = AccountBillColumns::for('taobao-wechat'); return response()->json($this->withLedgerRemarks($data, 'taobao-wechat')); } + /** @return array */ + private function emptySummary(): array + { + return [ + 'income' => 0, + 'expense' => 0, + 'opening_balance' => null, + 'opening_balance_date' => null, + 'opening_balance_source' => null, + 'balance' => null, + 'balance_source' => 'unavailable', + 'count' => 0, + ]; + } + public function filterOptions(TaobaoWechatSettlementBillRepository $repository) { $this->authorizeStoreReceivingBills(); diff --git a/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php b/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php index 356d2f76..ffd3d67e 100644 --- a/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php +++ b/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php @@ -57,14 +57,8 @@ class TaobaoAlipaySettlementBillRepository $perPage = min(max(1, $perPage), 100); $query = $this->query(); $this->applyFilters($query, $filters); - $summary = (clone $query)->selectRaw( - 'COALESCE(SUM(income_amount), 0) AS income_total, ' - . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count' - )->first(); - $total = (int) ($summary->row_count ?? 0); - $income = (float) ($summary->income_total ?? 0); - $expense = (float) ($summary->expense_total ?? 0); - $opening = $this->openingBalance($filters); + $summary = $this->summaryForQuery($query, $filters); + $total = $summary['count']; $sortColumn = $this->sortColumn($sort); $direction = $direction === 'asc' ? 'asc' : 'desc'; $query->orderBy($sortColumn, $direction); @@ -79,15 +73,7 @@ class TaobaoAlipaySettlementBillRepository 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage, - 'summary' => [ - 'income' => $income, - 'expense' => $expense, - 'opening_balance' => $opening['balance'], - 'opening_balance_date' => $opening['date'], - 'opening_balance_source' => $opening['source'], - 'balance' => round($opening['balance'] + $income - $expense, 2), - 'count' => $total, - ], + 'summary' => $summary, 'sync' => [ 'status' => 'succeeded', 'last_succeeded_at' => $this->connection->table(self::IMPORT_TABLE) @@ -96,6 +82,46 @@ class TaobaoAlipaySettlementBillRepository ]; } + /** + * 当前筛选条件的账户余额汇总。 + * + * 淘系微信页签本身没有余额列,直接复用这里的期初、收支和期末口径, + * 避免两个页签对同一账期计算出不同的账户余额。 + * + * @return array{income:float,expense:float,opening_balance:float,opening_balance_date:?string,opening_balance_source:string,balance:float,count:int} + */ + public function summary(array $filters): array + { + $query = $this->query(); + $this->applyFilters($query, $filters); + + return $this->summaryForQuery($query, $filters); + } + + /** + * @return array{income:float,expense:float,opening_balance:float,opening_balance_date:?string,opening_balance_source:string,balance:float,count:int} + */ + private function summaryForQuery(Builder $query, array $filters): array + { + $row = (clone $query)->selectRaw( + 'COALESCE(SUM(income_amount), 0) AS income_total, ' + . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count' + )->first(); + $income = (float) ($row->income_total ?? 0); + $expense = (float) ($row->expense_total ?? 0); + $opening = $this->openingBalance($filters); + + return [ + 'income' => $income, + 'expense' => $expense, + 'opening_balance' => $opening['balance'], + 'opening_balance_date' => $opening['date'], + 'opening_balance_source' => $opening['source'], + 'balance' => round($opening['balance'] + $income - $expense, 2), + 'count' => (int) ($row->row_count ?? 0), + ]; + } + /** @return iterable> */ public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable { diff --git a/resources/assets/js/components/business/AccountBills.vue b/resources/assets/js/components/business/AccountBills.vue index ac851e33..721858d8 100644 --- a/resources/assets/js/components/business/AccountBills.vue +++ b/resources/assets/js/components/business/AccountBills.vue @@ -5,6 +5,7 @@ :class="{ 'tw-stats--promotion-only': isPromotionMarketing && !compact, 'tw-stats--promotion-compact': compact, + 'tw-stats--taobao-wechat': billType === 'taobao-wechat', }" >
{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(){if(this.invalidDateRange)return;const dateRangeChanged=this.queryFilters.start!==this.filters.start||this.queryFilters.end!==this.filters.end;this.queryFilters={...this.filters};this.currentPage=1;const requests=[this.fetch()];if(this.usesMirrorOptions&&!this.compact&&dateRangeChanged)requests.push(this.fetchFilterOptions());return Promise.all(requests);},resetFilters(){this.filters=filtersForRange();return this.applyFilters();},changePage(page){this.currentPage=page;this.fetch();this.$nextTick(()=>{if(this.$refs.scroll)this.$refs.scroll.scrollTop=0;});}, openDatePicker(refName){const picker=this.$refs[refName];if(picker&&picker.fp)picker.fp.open();}, @@ -369,7 +385,7 @@ export default { changeDate(field,value){const date=value||'';if(this.filters[field]===date)return;this.filters[field]=date;}, 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})}`;},moneyWan(value){return`¥${(Number(value||0)/10000).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}万`;}, + 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})}`;},moneyWan(value){if(value===null||value===undefined||value==='')return'—';return`¥${(Number(value||0)/10000).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2})}万`;}, exportRows(){ if(!this.canExport||this.exporting)return; if(this.exportMode==='page-visible'){this.downloadCurrentPage();return;} @@ -450,7 +466,7 @@ export default {