feat: 淘系微信账单增加淘系支付宝账户余额 #898

Merged
qiuxiang merged 2 commits from feature/qiuxiang-taobao-wechat-alipay-balance into main 2026-11-05 18:36:24 +08:00
6 changed files with 144 additions and 24 deletions

View File

@ -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<string,int|float|null> */
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();

View File

@ -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<int,array<string,mixed>> */
public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable
{

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,7 @@ namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\TaobaoAlipaySettlementBillRepository;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use RuntimeException;
use Tests\Feature\FeatureTestCase;
@ -43,6 +44,18 @@ class TaobaoWechatReceivingBillsTest extends FeatureTestCase
]);
$this->app->instance(TaobaoWechatSettlementBillRepository::class, $repository);
$alipayRepository = Mockery::mock(TaobaoAlipaySettlementBillRepository::class);
$alipayRepository->shouldReceive('summary')->once()->with($filters)->andReturn([
'income' => 320.0,
'expense' => 25.0,
'opening_balance' => 230.0,
'opening_balance_date' => '2026-08-31',
'opening_balance_source' => 'previous_period',
'balance' => 525.0,
'count' => 12,
]);
$this->app->instance(TaobaoAlipaySettlementBillRepository::class, $alipayRepository);
$this->withExceptionHandling()
->loginAs()
->getJson(route('ecommerce-finance.account-bills.index', [
@ -54,7 +67,12 @@ class TaobaoWechatReceivingBillsTest extends FeatureTestCase
->assertOk()
->assertJsonPath('data.0.payload.entry_type', '收入')
->assertJsonPath('columns.title', '淘系微信账户')
->assertJsonPath('summary.count', 41);
->assertJsonPath('summary.count', 41)
->assertJsonPath('summary.opening_balance', 230)
->assertJsonPath('summary.opening_balance_date', '2026-08-31')
->assertJsonPath('summary.opening_balance_source', 'previous_period')
->assertJsonPath('summary.balance', 525)
->assertJsonPath('summary.balance_source', 'taobao_alipay');
}
public function test_filter_options_and_selected_column_export_use_the_same_mirror(): void

View File

@ -58,6 +58,13 @@ class TaobaoAlipaySettlementBillRepositoryTest extends TestCase
$this->assertSame('2026-09-30', $result['summary']['opening_balance_date']);
$this->assertSame('previous_period', $result['summary']['opening_balance_source']);
$this->assertSame(275.0, $result['summary']['balance']);
$this->assertSame(
$result['summary'],
app(TaobaoAlipaySettlementBillRepository::class)->summary([
'start' => '2026-10-01',
'end' => '2026-10-31',
]),
);
}
public function test_month_balance_respects_store_filter(): void

View File

@ -82,6 +82,22 @@ class AccountBillsTableTest extends TestCase
$this->assertStringContainsString("netLabel=JD_BILL_TABS.concat(['vipshop-jx']).indexOf(this.billType)!==-1", $source);
}
public function test_taobao_wechat_places_alipay_balances_before_income(): void
{
$source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue');
$this->assertStringContainsString("'tw-stats--taobao-wechat': billType === 'taobao-wechat'", $source);
$this->assertStringContainsString("if(this.billType==='taobao-wechat')", $source);
$this->assertStringContainsString("label:'期初余额'", $source);
$this->assertStringContainsString("label:'期末余额(最新)'", $source);
$this->assertStringContainsString("this.summary.balance_source==='unavailable'?'淘系支付宝余额暂不可用':'同淘系支付宝账户'", $source);
$this->assertLessThan(
strpos($source, "label:'收入合计'"),
strpos($source, "label:'期初余额'"),
);
$this->assertStringContainsString('.tw-stats--taobao-wechat{grid-template-columns:repeat(5,minmax(0,1fr))}', $source);
}
public function test_promotion_marketing_card_opens_the_store_detail_modal(): void
{
$source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue');