CaiWuXiTong/modules/EcommerceFinance/Services/StoreReceivingBalanceOverviewService.php

1323 lines
51 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
use Modules\EcommerceFinance\Http\Controllers\PinduoduoAccountBills;
use Modules\EcommerceFinance\Http\Controllers\TaobaoWechatAccountBills;
use Modules\EcommerceFinance\Models\AccountBill;
use Modules\EcommerceFinance\Repositories\DouyinAuthoritativeBillRepository;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\TaobaoWechatOpeningBalanceRepository;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use Modules\EcommerceFinance\Repositories\VipshopJxOpeningBalanceRepository;
use RuntimeException;
use Throwable;
class StoreReceivingBalanceOverviewService
{
private const TAOBAO_ALIPAY_ROW_TABLE = 'taobao_alipay_bill_rows';
private const TAOBAO_WECHAT_ROW_TABLE = 'taobao_wechat_bill_rows';
private const TAOBAO_WECHAT_OPENING_TABLE = 'taobao_wechat_opening_balances';
private const PINDUODUO_ROW_TABLE = 'pdd_settlement_bill_rows';
private const PINDUODUO_SNAPSHOT_TABLE = 'pdd_platform_balance_snapshots';
private const JD_WALLET_PERIOD_TABLE = 'jd_wallet_bill_periods';
private const JD_WALLET_ROW_TABLE = 'jd_wallet_bill_rows';
private const VIPSHOP_MP_SNAPSHOT_TABLE = 'vipshop_mp_opening_balance_snapshots';
private const XIAOHONGSHU_BALANCE_START = '2026-10-01';
/** @var array<int,array{slug:string,name:string}> */
public const PLATFORMS = [
['slug' => 'taobao-alipay', 'name' => '淘系支付宝账户'],
['slug' => 'taobao-wechat', 'name' => '淘系微信账户'],
['slug' => 'pinduoduo', 'name' => '拼多多账户'],
['slug' => 'jd-wallet', 'name' => '京东网银钱包账户'],
['slug' => 'douyin', 'name' => '抖音账户'],
['slug' => 'vipshop', 'name' => '唯品会MP账户'],
['slug' => 'vipshop-jx', 'name' => '唯品会JX账户'],
['slug' => 'xiaohongshu', 'name' => '小红书账户'],
];
public const MONTHLY_CLOSING_MONTHS = [
['key' => '2026-07', 'label' => '7月'],
['key' => '2026-08', 'label' => '8月'],
['key' => '2026-09', 'label' => '9月'],
['key' => '2026-10', 'label' => '10月'],
];
public function __construct(
private readonly AlipayBills $alipayBills,
private readonly TaobaoWechatAccountBills $taobaoWechatBills,
private readonly PinduoduoAccountBills $pinduoduoBills,
private readonly AccountBills $accountBills,
) {}
/** @return array<string,mixed> */
public function overview(string $periodStart, ?string $periodEnd = null): array
{
[$periodStart, $periodEnd, $periodMonth] = $this->periodForRange($periodStart, $periodEnd);
$platforms = [];
$availableTotals = [
'opening_balance' => 0.0,
'income' => 0.0,
'expense' => 0.0,
];
$availableCount = 0;
foreach (self::PLATFORMS as $definition) {
try {
$summary = $this->fetchPlatformSummary(
$definition['slug'],
$periodStart,
$periodEnd,
);
if ($summary['opening_balance'] === null) {
throw new RuntimeException('平台期初余额未就绪');
}
$availableCount++;
$availableTotals['opening_balance'] += (float) $summary['opening_balance'];
$availableTotals['income'] += (float) $summary['income'];
$availableTotals['expense'] += (float) $summary['expense'];
$platforms[] = [
'slug' => $definition['slug'],
'name' => $definition['name'],
'available' => true,
'opening_balance' => round((float) $summary['opening_balance'], 2),
'income' => round((float) $summary['income'], 2),
'expense' => round((float) $summary['expense'], 2),
'balance' => round(
(float) $summary['opening_balance']
+ (float) $summary['income']
- (float) $summary['expense'],
2,
),
'error' => null,
];
} catch (Throwable $exception) {
if ($exception->getMessage() !== '平台期初余额未就绪') {
report($exception);
}
$platforms[] = [
'slug' => $definition['slug'],
'name' => $definition['name'],
'available' => false,
'opening_balance' => null,
'income' => null,
'expense' => null,
'balance' => null,
'error' => $this->safeErrorMessage($exception),
];
}
}
$complete = $availableCount === count(self::PLATFORMS);
$openingBalance = $complete ? round($availableTotals['opening_balance'], 2) : null;
$income = $complete ? round($availableTotals['income'], 2) : null;
$expense = $complete ? round($availableTotals['expense'], 2) : null;
return [
'month' => $periodMonth,
'period' => [
'start' => $periodStart,
'end' => $periodEnd,
],
'summary' => [
'opening_balance' => $openingBalance,
'income' => $income,
'expense' => $expense,
'balance' => $complete
? round($openingBalance + $income - $expense, 2)
: null,
'platform_count' => count(self::PLATFORMS),
'available_platform_count' => $availableCount,
'complete' => $complete,
],
'platforms' => $platforms,
];
}
/**
* 2026 年 7-10 月各平台期末余额矩阵。环比为 10 月相对 9 月。
*
* @return array<string,mixed>
*/
public function monthlyClosing(): array
{
$monthOverviews = [];
foreach (self::MONTHLY_CLOSING_MONTHS as $month) {
$monthOverviews[$month['key']] = $this->overview($month['key']);
}
$rows = [];
$availableRows = 0;
foreach (self::PLATFORMS as $definition) {
$balances = [];
foreach (self::MONTHLY_CLOSING_MONTHS as $month) {
$platform = $this->platformFromOverview(
$monthOverviews[$month['key']],
$definition['slug'],
);
$balances[$month['key']] = $platform !== null
&& $platform['available']
&& is_numeric($platform['balance'] ?? null)
? round((float) $platform['balance'], 2)
: null;
}
$available = ! in_array(null, $balances, true);
if ($available) {
$availableRows++;
}
$rows[] = $this->monthlyClosingRow(
$definition['slug'],
$definition['name'],
$balances,
$available,
);
}
$totals = [];
foreach (self::MONTHLY_CLOSING_MONTHS as $month) {
$overview = $monthOverviews[$month['key']];
$totals[$month['key']] = $overview['summary']['complete']
&& is_numeric($overview['summary']['balance'] ?? null)
? round((float) $overview['summary']['balance'], 2)
: null;
}
return [
'months' => self::MONTHLY_CLOSING_MONTHS,
'comparison' => [
'current' => '2026-10',
'previous' => '2026-09',
],
'summary' => [
'platform_count' => count(self::PLATFORMS),
'available_platform_count' => $availableRows,
'complete' => $availableRows === count(self::PLATFORMS)
&& ! in_array(null, $totals, true),
],
'total' => $this->monthlyClosingRow(
'total',
'合计',
$totals,
! in_array(null, $totals, true),
),
'platforms' => $rows,
];
}
/** @param array<string,mixed> $overview */
private function platformFromOverview(array $overview, string $slug): ?array
{
foreach ($overview['platforms'] ?? [] as $platform) {
if (($platform['slug'] ?? null) === $slug) {
return $platform;
}
}
return null;
}
/**
* @param array<string,float|null> $balances
* @return array<string,mixed>
*/
private function monthlyClosingRow(
string $slug,
string $name,
array $balances,
bool $available,
): array
{
$currentMonth = (string) self::MONTHLY_CLOSING_MONTHS[array_key_last(self::MONTHLY_CLOSING_MONTHS)]['key'];
$previousMonth = (string) self::MONTHLY_CLOSING_MONTHS[array_key_last(self::MONTHLY_CLOSING_MONTHS) - 1]['key'];
$current = $balances[$currentMonth];
$previous = $balances[$previousMonth];
$changeAmount = $current !== null && $previous !== null
? round($current - $previous, 2)
: null;
$changeRate = $changeAmount !== null && abs($previous) >= 0.005
? round($changeAmount / abs($previous) * 100, 2)
: ($changeAmount === 0.0 ? 0.0 : null);
return [
'slug' => $slug,
'name' => $name,
'available' => $available,
'balances' => $balances,
'change' => [
'amount' => $changeAmount,
'rate' => $changeRate,
],
];
}
/**
* 收入合计的平台、店铺、账单项目明细。
*
* 各平台仍沿用对应账单页的同一数据源和账期口径;这里只汇总收入行,
* 明细金额统一返回元,页面再按万元展示。
*
* @return array<string,mixed>
*/
public function incomeDetail(string $periodStart, ?string $periodEnd = null): array
{
return $this->flowDetail($periodStart, $periodEnd, 'income');
}
/**
* 支出合计的平台、店铺、账单项目明细。
*
* 支出明细沿用各平台账单页的同一数据源和账期口径,只汇总支出行;
* 明细金额统一返回元,页面再按万元展示。
*
* @return array<string,mixed>
*/
public function expenseDetail(string $periodStart, ?string $periodEnd = null): array
{
return $this->flowDetail($periodStart, $periodEnd, 'expense');
}
/**
* 期末余额合计的平台明细。
*
* 直接复用总览的平台余额,确保明细合计与期末余额卡片口径一致。
* 明细金额统一返回元,页面再按万元展示。
*
* @return array<string,mixed>
*/
public function balanceDetail(string $periodStart, ?string $periodEnd = null): array
{
$overview = $this->overview($periodStart, $periodEnd);
$details = [];
foreach ($overview['platforms'] as $platform) {
if (! $platform['available'] || ! is_numeric($platform['balance'] ?? null)) {
continue;
}
$details[] = [
'platform_slug' => $platform['slug'],
'platform' => $platform['name'],
'store_name' => '',
'bill_item' => '',
'amount' => round((float) $platform['balance'], 2),
];
}
return [
'month' => $overview['month'],
'period' => $overview['period'],
'summary' => [
'balance' => $overview['summary']['balance'],
'platform_count' => $overview['summary']['platform_count'],
'available_platform_count' => $overview['summary']['available_platform_count'],
'complete' => $overview['summary']['complete'],
],
'details' => $details,
];
}
/**
* 期末余额的店铺明细。
*
* 每个店铺统一按「期初余额 + 收入 - 支出」计算;某一店铺缺少期初时
* 返回 null,由页面显示为“—”,不把未知期初静默当成 0。
*
* @return array<string,mixed>
*/
public function balanceStoreDetail(string $periodStart, ?string $periodEnd = null): array
{
[$periodStart, $periodEnd, $periodMonth] = $this->periodForRange($periodStart, $periodEnd);
$details = [];
$platformCount = 0;
$storeCount = 0;
$readyStoreCount = 0;
$balance = 0.0;
foreach (self::PLATFORMS as $index => $definition) {
try {
$rows = $this->fetchPlatformStoreBalances(
$definition['slug'],
$periodStart,
$periodEnd,
);
if ($rows === []) {
continue;
}
$platformCount++;
} catch (Throwable $exception) {
throw new RuntimeException('店铺期末余额明细暂时无法读取,请稍后重试。', 0, $exception);
}
$platformRows = [];
foreach ($rows as $row) {
$storeName = trim((string) ($row['store_name'] ?? ''));
$storeName = $storeName !== '' ? $storeName : '未标注店铺';
$opening = is_numeric($row['opening_balance'] ?? null)
? round((float) $row['opening_balance'], 2)
: null;
$income = round((float) ($row['income'] ?? 0), 2);
$expense = round((float) ($row['expense'] ?? 0), 2);
$amount = $opening === null
? null
: round($opening + $income - $expense, 2);
$storeCount++;
if ($amount !== null) {
$readyStoreCount++;
$balance += $amount;
}
$platformRows[] = [
'platform_slug' => $definition['slug'],
'platform' => $definition['name'],
'store_name' => $storeName,
'bill_item' => '',
'opening_balance' => $opening,
'income' => $income,
'expense' => $expense,
'amount' => $amount,
'available' => $amount !== null,
'_platform_order' => $index,
];
}
usort($platformRows, static function (array $left, array $right): int {
return [
$left['amount'] === null ? 1 : 0,
$left['amount'] === null ? 0 : -$left['amount'],
$left['store_name'],
] <=> [
$right['amount'] === null ? 1 : 0,
$right['amount'] === null ? 0 : -$right['amount'],
$right['store_name'],
];
});
array_push($details, ...$platformRows);
}
foreach ($details as &$detail) {
unset($detail['_platform_order']);
}
unset($detail);
$complete = $platformCount === count(self::PLATFORMS)
&& $storeCount > 0
&& $readyStoreCount === $storeCount;
return [
'month' => $periodMonth,
'period' => [
'start' => $periodStart,
'end' => $periodEnd,
],
'summary' => [
'balance' => $complete ? round($balance, 2) : null,
'platform_count' => count(self::PLATFORMS),
'available_platform_count' => $platformCount,
'store_count' => $storeCount,
'ready_store_count' => $readyStoreCount,
'complete' => $complete,
],
'details' => $details,
];
}
/** @return array<string,mixed> */
private function flowDetail(string $periodStart, ?string $periodEnd, string $flow): array
{
[$periodStart, $periodEnd, $periodMonth] = $this->periodForRange($periodStart, $periodEnd);
$platformOrder = [];
$details = [];
$totalAmount = 0.0;
foreach (self::PLATFORMS as $index => $definition) {
$platformOrder[$definition['slug']] = $index;
$buckets = [];
try {
$rows = $flow === 'income'
? $this->fetchPlatformIncomeRows(
$definition['slug'],
$periodStart,
$periodEnd,
)
: $this->fetchPlatformExpenseRows(
$definition['slug'],
$periodStart,
$periodEnd,
);
foreach ($rows as $row) {
$amount = round((float) ($row[$flow] ?? 0), 2);
if ($amount <= 0) {
continue;
}
$storeName = trim((string) ($row['store_name'] ?? ''));
$billItem = trim((string) ($row['bill_item'] ?? ''));
$storeName = $storeName !== '' ? $storeName : '未标注店铺';
$billItem = $billItem !== '' ? $billItem : '未标注账单项目';
$buckets[$storeName][$billItem] = round(
(float) ($buckets[$storeName][$billItem] ?? 0) + $amount,
2,
);
$totalAmount += $amount;
}
} catch (Throwable $exception) {
$label = $flow === 'income' ? '收入' : '支出';
throw new RuntimeException($label . '明细暂时无法读取,请稍后重试。', 0, $exception);
}
foreach ($buckets as $storeName => $items) {
foreach ($items as $billItem => $amount) {
$details[] = [
'platform_slug' => $definition['slug'],
'platform' => $definition['name'],
'store_name' => (string) $storeName,
'bill_item' => (string) $billItem,
'amount' => round((float) $amount, 2),
];
}
}
}
usort($details, static function (array $left, array $right) use ($platformOrder): int {
return [
$platformOrder[$left['platform_slug']] ?? PHP_INT_MAX,
-$left['amount'],
$left['store_name'],
$left['bill_item'],
] <=> [
$platformOrder[$right['platform_slug']] ?? PHP_INT_MAX,
-$right['amount'],
$right['store_name'],
$right['bill_item'],
];
});
return [
'month' => $periodMonth,
'period' => [
'start' => $periodStart,
'end' => $periodEnd,
],
'summary' => [
$flow => round($totalAmount, 2),
'platform_count' => count(self::PLATFORMS),
'available_platform_count' => count(self::PLATFORMS),
'complete' => true,
],
'details' => $details,
];
}
/** @return array{0:string,1:string,2:string} */
private function periodForRange(string $start, ?string $end): array
{
if ($end === null && preg_match('/^\d{4}-\d{2}$/', $start) === 1) {
$month = CarbonImmutable::createFromFormat('!Y-m', $start, 'Asia/Shanghai');
if ($month instanceof CarbonImmutable && $month->format('Y-m') === $start) {
return [
$month->startOfMonth()->format('Y-m-d'),
$month->endOfMonth()->format('Y-m-d'),
$start,
];
}
}
try {
$periodStart = CarbonImmutable::createFromFormat('!Y-m-d', $start, 'Asia/Shanghai');
$periodEnd = $end !== null
? CarbonImmutable::createFromFormat('!Y-m-d', $end, 'Asia/Shanghai')
: null;
} catch (Throwable) {
throw new RuntimeException('invalid_balance_overview_period');
}
if (! $periodStart instanceof CarbonImmutable
|| $periodStart->format('Y-m-d') !== $start
|| ! $periodEnd instanceof CarbonImmutable
|| $periodEnd->format('Y-m-d') !== (string) $end
|| $periodEnd->lt($periodStart)
) {
throw new RuntimeException('invalid_balance_overview_period');
}
return [
$periodStart->format('Y-m-d'),
$periodEnd->format('Y-m-d'),
$periodStart->format('Y-m'),
];
}
/**
* 返回某平台所选月份的收入行。各仓库的导出游标复用账单页现有筛选口径,
* 避免为明细页复制一套容易漂移的查询。
*
* @return iterable<int,array{store_name?:mixed,bill_item?:mixed,income?:mixed}>
*/
protected function fetchPlatformIncomeRows(string $platform, string $start, string $end): iterable
{
return $this->fetchPlatformRows($platform, $start, $end, 'income');
}
/**
* 返回某平台所选月份的支出行。
*
* @return iterable<int,array<string,mixed>>
*/
protected function fetchPlatformExpenseRows(string $platform, string $start, string $end): iterable
{
return $this->fetchPlatformRows($platform, $start, $end, 'expense');
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
protected function fetchPlatformStoreBalances(string $platform, string $start, string $end): array
{
return match ($platform) {
'taobao-alipay' => $this->taobaoAlipayStoreBalances($start, $end),
'taobao-wechat' => $this->taobaoWechatStoreBalances($start, $end),
'pinduoduo' => $this->pinduoduoStoreBalances($start, $end),
'jd-wallet' => $this->jdWalletStoreBalances($start, $end),
'douyin' => $this->douyinStoreBalances($start, $end),
'vipshop' => $this->vipshopStoreBalances($start, $end),
'vipshop-jx' => $this->vipshopJxStoreBalances($start, $end),
'xiaohongshu' => $this->xiaohongshuStoreBalances($start, $end),
default => throw new RuntimeException('unsupported_balance_overview_platform'),
};
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function taobaoAlipayStoreBalances(string $start, string $end): array
{
$connection = $this->mirrorConnection();
$activity = $this->groupedActivity(
$connection->table(self::TAOBAO_ALIPAY_ROW_TABLE)
->where('occurred_at', '>=', $start . ' 00:00:00')
->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'),
);
$cutoff = $start . ' 00:00:00';
$periodEnd = $this->dayAfter($end) . ' 00:00:00';
$ranked = $connection->table(self::TAOBAO_ALIPAY_ROW_TABLE)
->where('occurred_at', '<', $cutoff)
->whereNotNull('alipay_account')
->where('alipay_account', '<>', '')
->select([
'store_name',
'alipay_account',
'occurred_at',
'source_row_number',
'income_amount',
'expense_amount',
'balance_amount',
])
->selectRaw(
'ROW_NUMBER() OVER (' .
'PARTITION BY alipay_account ' .
'ORDER BY occurred_at DESC, source_row_number DESC' .
') AS row_rank',
);
$latestRows = $connection->query()
->fromSub($ranked, 'latest_store_balances')
->where('row_rank', 1)
->get();
$opening = [];
if ($latestRows->isNotEmpty()) {
foreach ($latestRows as $row) {
$storeName = $this->storeName($row->store_name);
$opening[$storeName] = round(
(float) ($opening[$storeName] ?? 0) + (float) $row->balance_amount,
2,
);
}
} else {
$firstRanked = $connection->table(self::TAOBAO_ALIPAY_ROW_TABLE)
->where('occurred_at', '>=', $cutoff)
->where('occurred_at', '<', $periodEnd)
->whereNotNull('alipay_account')
->where('alipay_account', '<>', '')
->select([
'store_name',
'alipay_account',
'occurred_at',
'source_row_number',
'income_amount',
'expense_amount',
'balance_amount',
])
->selectRaw(
'ROW_NUMBER() OVER (' .
'PARTITION BY alipay_account ' .
'ORDER BY occurred_at ASC, source_row_number ASC' .
') AS row_rank',
);
$firstRows = $connection->query()
->fromSub($firstRanked, 'first_store_balances')
->where('row_rank', 1)
->get();
foreach ($firstRows as $row) {
$storeName = $this->storeName($row->store_name);
$opening[$storeName] = round(
(float) ($opening[$storeName] ?? 0)
+ (float) $row->balance_amount
- (float) $row->income_amount
+ (float) $row->expense_amount,
2,
);
}
}
return $this->mergeStoreBalances($opening, $activity);
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function taobaoWechatStoreBalances(string $start, string $end): array
{
$connection = $this->mirrorConnection();
$activity = $this->groupedActivity(
$connection->table(self::TAOBAO_WECHAT_ROW_TABLE)
->where('occurred_at', '>=', $start . ' 00:00:00')
->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'),
);
$opening = [];
if ($connection->getSchemaBuilder()->hasTable(self::TAOBAO_WECHAT_OPENING_TABLE)) {
$baseline = $connection->table(self::TAOBAO_WECHAT_OPENING_TABLE)
->where('balance_date', TaobaoWechatOpeningBalanceRepository::BASELINE_DATE)
->select('store_name')
->selectRaw('COALESCE(SUM(balance), 0) AS balance')
->groupBy('store_name')
->get();
foreach ($baseline as $row) {
$opening[$this->storeName($row->store_name)] = round((float) $row->balance, 2);
}
if (CarbonImmutable::parse($start)->gt(CarbonImmutable::parse('2026-07-01'))) {
$movement = $this->groupedActivity(
$connection->table(self::TAOBAO_WECHAT_ROW_TABLE)
->where('occurred_at', '>=', '2026-07-01 00:00:00')
->where('occurred_at', '<', $start . ' 00:00:00'),
);
foreach ($opening as $storeName => $balance) {
$row = $movement[$storeName] ?? ['income' => 0.0, 'expense' => 0.0];
$opening[$storeName] = round(
$balance + (float) $row['income'] - (float) $row['expense'],
2,
);
}
}
}
return $this->mergeStoreBalances($opening, $activity);
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function pinduoduoStoreBalances(string $start, string $end): array
{
$connection = $this->mirrorConnection();
$activity = $this->groupedActivity(
$connection->table(self::PINDUODUO_ROW_TABLE)
->where('occurred_at', '>=', $start . ' 00:00:00')
->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'),
);
if (! $connection->getSchemaBuilder()->hasTable(self::PINDUODUO_SNAPSHOT_TABLE)) {
return $this->mergeNormalizedStoreBalances([], [], $activity);
}
$monthStart = CarbonImmutable::parse($start)->startOfMonth()->format('Y-m-d');
$balanceDate = $connection->table(self::PINDUODUO_SNAPSHOT_TABLE)
->where('period_end', '<', $monthStart)
->orderBy('period_end')
->value('period_end');
if ($balanceDate === null) {
return $this->mergeNormalizedStoreBalances([], [], $activity);
}
$base = [];
foreach ($connection->table(self::PINDUODUO_SNAPSHOT_TABLE)
->where('period_end', $balanceDate)
->select(['store_name', 'normalized_store_name'])
->selectRaw('COALESCE(SUM(balance), 0) AS balance')
->groupBy('store_name', 'normalized_store_name')
->get() as $row) {
$normalized = (string) $row->normalized_store_name;
$base[$normalized] = [
'store_name' => $this->storeName($row->store_name),
'balance' => round((float) $row->balance, 2),
];
}
$movement = [];
foreach ($connection->table(self::PINDUODUO_ROW_TABLE)
->where('occurred_at', '>=', $balanceDate . ' 00:00:00')
->where('occurred_at', '<', $monthStart . ' 00:00:00')
->select('store_name')
->selectRaw(
'COALESCE(SUM(income_amount), 0) AS income, '
. 'COALESCE(SUM(expense_amount), 0) AS expense',
)
->groupBy('store_name')
->get() as $row) {
$normalized = JushuitanShopBalanceRepository::normalizeStoreName((string) $row->store_name);
$movement[$normalized] = [
'income' => (float) $row->income,
'expense' => (float) $row->expense,
];
}
return $this->mergeNormalizedStoreBalances($base, $movement, $activity);
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function jdWalletStoreBalances(string $start, string $end): array
{
$connection = $this->mirrorConnection();
$generations = $connection->table(self::JD_WALLET_PERIOD_TABLE)
->pluck('active_generation')
->filter()
->map(static fn (mixed $value): string => (string) $value)
->values()
->all();
if ($generations === []) {
throw new RuntimeException('jd_wallet_bill_mirror_not_ready');
}
$activity = $this->groupedActivity(
$connection->table(self::JD_WALLET_ROW_TABLE)
->whereIn('generation', $generations)
->where('occurred_at', '>=', $start . ' 00:00:00')
->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00'),
'income',
'expense',
);
$cutoff = $start . ' 00:00:00';
$periodEnd = $this->dayAfter($end) . ' 00:00:00';
$ranked = $connection->table(self::JD_WALLET_ROW_TABLE)
->whereIn('generation', $generations)
->whereNotNull('balance')
->where('occurred_at', '<', $cutoff)
->select([
'store_name',
'account_name',
'occurred_at',
'income',
'expense',
'balance',
'record_code',
])
->selectRaw(
'ROW_NUMBER() OVER (' .
'PARTITION BY store_name, account_name ' .
'ORDER BY occurred_at DESC, LENGTH(record_code) DESC, record_code DESC' .
') AS row_rank',
);
$latestRows = $connection->query()
->fromSub($ranked, 'latest_jd_balances')
->where('row_rank', 1)
->get();
$opening = [];
if ($latestRows->isNotEmpty()) {
foreach ($latestRows as $row) {
$storeName = $this->storeName($row->store_name);
$opening[$storeName] = round(
(float) ($opening[$storeName] ?? 0) + (float) $row->balance,
2,
);
}
} else {
$firstRanked = $connection->table(self::JD_WALLET_ROW_TABLE)
->whereIn('generation', $generations)
->whereNotNull('balance')
->where('occurred_at', '>=', $cutoff)
->where('occurred_at', '<', $periodEnd)
->select([
'store_name',
'account_name',
'occurred_at',
'income',
'expense',
'balance',
'record_code',
])
->selectRaw(
'ROW_NUMBER() OVER (' .
'PARTITION BY store_name, account_name ' .
'ORDER BY occurred_at ASC, LENGTH(record_code) ASC, record_code ASC' .
') AS row_rank',
);
$firstRows = $connection->query()
->fromSub($firstRanked, 'first_jd_balances')
->where('row_rank', 1)
->get();
foreach ($firstRows as $row) {
$storeName = $this->storeName($row->store_name);
$opening[$storeName] = round(
(float) ($opening[$storeName] ?? 0)
+ (float) $row->balance
- (float) $row->income
+ (float) $row->expense,
2,
);
}
}
return $this->mergeStoreBalances($opening, $activity);
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function douyinStoreBalances(string $start, string $end): array
{
$repository = app(DouyinAuthoritativeBillRepository::class);
if (! $repository->available()) {
throw new RuntimeException('douyin_authoritative_bills_not_ready');
}
return $repository->storeBalances([
'start' => $start,
'end' => $end,
]);
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function vipshopStoreBalances(string $start, string $end): array
{
$activity = $this->accountBillActivity('vipshop', $start, $end, true);
$connection = $this->mirrorConnection();
$opening = [];
if ($connection->getSchemaBuilder()->hasTable(self::VIPSHOP_MP_SNAPSHOT_TABLE)) {
foreach ($connection->table(self::VIPSHOP_MP_SNAPSHOT_TABLE)
->where('period_end', '2026-06-30')
->select('store_name')
->selectRaw('COALESCE(SUM(balance), 0) AS balance')
->groupBy('store_name')
->get() as $row) {
$opening[$this->storeName($row->store_name)] = round((float) $row->balance, 2);
}
}
$monthStart = CarbonImmutable::parse($start)->startOfMonth();
if ($opening !== [] && $monthStart->gt(CarbonImmutable::parse('2026-07-01'))) {
$movement = $this->accountBillActivity(
'vipshop',
'2026-07-01',
$monthStart->subDay()->format('Y-m-d'),
true,
);
foreach ($opening as $storeName => $balance) {
$row = $movement[$storeName] ?? ['income' => 0.0, 'expense' => 0.0];
$opening[$storeName] = round(
$balance + (float) $row['income'] - (float) $row['expense'],
2,
);
}
}
return $this->mergeStoreBalances($opening, $activity);
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function vipshopJxStoreBalances(string $start, string $end): array
{
$activity = $this->accountBillActivity('vipshop-jx', $start, $end, true);
$openingData = app(VipshopJxOpeningBalanceRepository::class)->openingBalance($start, $end);
$opening = $openingData['source'] === 'none'
? null
: round((float) $openingData['balance'], 2);
$storeNames = array_keys($activity);
if ($storeNames === []) {
$storeNames = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', 'vipshop-jx')
->whereNull('deleted_at')
->whereNotNull('store_name')
->where('store_name', '<>', '')
->distinct()
->orderBy('store_name')
->pluck('store_name')
->map(fn (mixed $value): string => $this->storeName($value))
->all();
}
$openingMap = count($storeNames) === 1 && $opening !== null
? [reset($storeNames) => $opening]
: [];
return $this->mergeStoreBalances($openingMap, $activity);
}
/**
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function xiaohongshuStoreBalances(string $start, string $end): array
{
$activity = $this->accountBillActivity('xiaohongshu', $start, $end, false);
$opening = [];
$monthStart = CarbonImmutable::parse($start)->startOfMonth();
if ($monthStart->lte(CarbonImmutable::parse(self::XIAOHONGSHU_BALANCE_START))) {
foreach (array_keys($activity) as $storeName) {
$opening[$storeName] = 0.0;
}
} elseif ($monthStart->gt(CarbonImmutable::parse(self::XIAOHONGSHU_BALANCE_START))) {
$movement = $this->accountBillActivity(
'xiaohongshu',
self::XIAOHONGSHU_BALANCE_START,
$monthStart->subDay()->format('Y-m-d'),
false,
);
foreach ($movement as $storeName => $row) {
$opening[$storeName] = round(
(float) $row['income'] - (float) $row['expense'],
2,
);
}
}
if ($opening === []) {
$storeNames = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', 'xiaohongshu')
->whereNull('deleted_at')
->whereNotNull('store_name')
->where('store_name', '<>', '')
->distinct()
->pluck('store_name')
->map(fn (mixed $value): string => $this->storeName($value))
->all();
foreach ($storeNames as $storeName) {
$opening[$storeName] = 0.0;
}
}
return $this->mergeStoreBalances($opening, $activity);
}
/**
* @return array<string,array{income:float,expense:float}>
*/
private function groupedActivity(
Builder|EloquentBuilder $query,
string $incomeColumn = 'income_amount',
string $expenseColumn = 'expense_amount',
): array {
$activity = [];
foreach ($query
->select('store_name')
->selectRaw(
'COALESCE(SUM(' . $incomeColumn . '), 0) AS income_total, '
. 'COALESCE(SUM(' . $expenseColumn . '), 0) AS expense_total',
)
->groupBy('store_name')
->get() as $row) {
$storeName = $this->storeName($row->store_name);
$activity[$storeName] = [
'income' => round((float) ($activity[$storeName]['income'] ?? 0) + (float) $row->income_total, 2),
'expense' => round((float) ($activity[$storeName]['expense'] ?? 0) + (float) $row->expense_total, 2),
];
}
return $activity;
}
/**
* @param array<string,float|int> $opening
* @param array<string,array{income:float,expense:float}> $activity
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function mergeStoreBalances(array $opening, array $activity): array
{
$storeNames = array_values(array_unique(array_merge(array_keys($opening), array_keys($activity))));
$rows = [];
foreach ($storeNames as $storeName) {
$rows[] = [
'store_name' => $storeName,
'opening_balance' => array_key_exists($storeName, $opening)
? round((float) $opening[$storeName], 2)
: null,
'income' => round((float) ($activity[$storeName]['income'] ?? 0), 2),
'expense' => round((float) ($activity[$storeName]['expense'] ?? 0), 2),
];
}
return $rows;
}
/**
* @param array<string,array{store_name:string,balance:float}> $base
* @param array<string,array{income:float,expense:float}> $movement
* @param array<string,array{income:float,expense:float}> $activity
* @return array<int,array{store_name:string,opening_balance:?float,income:float,expense:float}>
*/
private function mergeNormalizedStoreBalances(array $base, array $movement, array $activity): array
{
$normalizedActivity = [];
$displayNames = [];
foreach ($activity as $storeName => $row) {
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName);
$displayNames[$normalized] = $storeName;
$normalizedActivity[$normalized]['income'] = round(
(float) ($normalizedActivity[$normalized]['income'] ?? 0) + (float) $row['income'],
2,
);
$normalizedActivity[$normalized]['expense'] = round(
(float) ($normalizedActivity[$normalized]['expense'] ?? 0) + (float) $row['expense'],
2,
);
}
$storeNames = array_values(array_unique(array_merge(
array_keys($base),
array_keys($movement),
array_keys($normalizedActivity),
)));
$rows = [];
foreach ($storeNames as $normalized) {
$storeName = $displayNames[$normalized]
?? ($base[$normalized]['store_name'] ?? '未标注店铺');
$opening = array_key_exists($normalized, $base)
? round(
(float) $base[$normalized]['balance']
+ (float) ($movement[$normalized]['income'] ?? 0)
- (float) ($movement[$normalized]['expense'] ?? 0),
2,
)
: null;
$rows[] = [
'store_name' => $storeName,
'opening_balance' => $opening,
'income' => round((float) ($normalizedActivity[$normalized]['income'] ?? 0), 2),
'expense' => round((float) ($normalizedActivity[$normalized]['expense'] ?? 0), 2),
];
}
return $rows;
}
/**
* @return array<string,array{income:float,expense:float}>
*/
private function accountBillActivity(
string $billType,
string $start,
string $end,
bool $bySettlePeriod,
): array {
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $billType)
->whereNull('deleted_at');
if ($bySettlePeriod) {
$query->where('payload->settle_period', '>=', substr($start, 0, 7))
->where('payload->settle_period', '<=', substr($end, 0, 7));
} else {
$query->where('occurred_at', '>=', $start . ' 00:00:00')
->where('occurred_at', '<', $this->dayAfter($end) . ' 00:00:00');
}
return $this->groupedActivity($query, 'income', 'expense');
}
private function mirrorConnection(): ConnectionInterface
{
return app(DatabaseManager::class)->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
private function storeName(mixed $value): string
{
$storeName = trim((string) $value);
return $storeName !== '' ? $storeName : '未标注店铺';
}
private function dayAfter(string $date): string
{
return CarbonImmutable::parse($date, 'Asia/Shanghai')->addDay()->format('Y-m-d');
}
/** @return iterable<int,array<string,mixed>> */
private function fetchPlatformRows(
string $platform,
string $start,
string $end,
string $flow,
): iterable
{
$filters = [
'start' => $start,
'end' => $end,
'business_type' => $flow === 'income' ? '收入' : '支出',
];
return match ($platform) {
'taobao-alipay' => app(StoreReceivingAlipayBillMirrorRepository::class)
->exportRows($filters, 'occurred_at', 'asc'),
'taobao-wechat' => app(TaobaoWechatSettlementBillRepository::class)
->exportAccountBills($filters, 'occurred_at', 'asc'),
'pinduoduo' => app(PinduoduoSettlementBillRepository::class)
->exportAccountBills($filters, 'occurred_at', 'asc'),
'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class)
->exportRows($filters, 'occurred_at', 'asc'),
'douyin' => app(StoreReceivingDouyinBillMirrorRepository::class)
->exportRows($filters, 'occurred_at', 'asc'),
'vipshop', 'vipshop-jx', 'xiaohongshu' => $this->accountBillRows(
$platform,
$start,
$end,
$flow,
),
default => throw new RuntimeException('unsupported_balance_overview_platform'),
};
}
/**
* 本系统落库账户按对应账单页相同的账期规则读取收入或支出:
* 唯品会按 payload.settle_period,小红书等按 occurred_at。
*
* @return iterable<int,array<string,mixed>>
*/
private function accountBillRows(string $platform, string $start, string $end, string $flow): iterable
{
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $platform)
->where($flow, '>', 0);
if (in_array($platform, ['vipshop', 'vipshop-jx'], true)) {
$settlePeriod = "JSON_UNQUOTE(JSON_EXTRACT(payload, '$.settle_period'))";
$query->whereRaw($settlePeriod . ' >= ?', [substr($start, 0, 7)])
->whereRaw($settlePeriod . ' <= ?', [substr($end, 0, 7)]);
} else {
$query->where('occurred_at', '>=', $start . ' 00:00:00')
->where('occurred_at', '<', CarbonImmutable::parse($end, 'Asia/Shanghai')->addDay()->format('Y-m-d') . ' 00:00:00');
}
foreach ($query->orderBy('id')->cursor() as $bill) {
yield [
'store_name' => (string) ($bill->store_name ?? ''),
'bill_item' => $platform === 'vipshop-jx'
? (string) data_get($bill->payload ?? [], 'second_remark', '')
: (string) ($bill->bill_item ?? ''),
'income' => (float) $bill->income,
'expense' => (float) $bill->expense,
];
}
}
/**
* @return array{opening_balance:float|null,income:float,expense:float}
*/
protected function fetchPlatformSummary(string $platform, string $start, string $end): array
{
$request = Request::create('/ecommerce-finance/account-bills/overview', 'GET', [
'start' => $start,
'end' => $end,
'page' => 1,
'limit' => 1,
'sort' => 'occurred_at',
'dir' => 'desc',
], [], [], ['HTTP_ACCEPT' => 'application/json']);
$response = match ($platform) {
'taobao-alipay' => $this->alipayBills->index(
$request,
app(StoreReceivingAlipayBillMirrorRepository::class),
),
'taobao-wechat' => $this->taobaoWechatBills->index(
$request,
app(TaobaoWechatSettlementBillRepository::class),
app(TaobaoWechatOpeningBalanceRepository::class),
),
'pinduoduo' => $this->pinduoduoBills->index(
$request,
app(PinduoduoSettlementBillRepository::class),
),
'jd-wallet', 'douyin', 'vipshop', 'vipshop-jx', 'xiaohongshu' => $this->accountBills->index(
$request,
$platform,
),
default => throw new RuntimeException('unsupported_balance_overview_platform'),
};
return $this->summaryFromResponse($response);
}
/** @return array{opening_balance:float|null,income:float,expense:float} */
private function summaryFromResponse(JsonResponse $response): array
{
$payload = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
if ($response->getStatusCode() !== 200 || ! is_array($payload)) {
throw new RuntimeException((string) ($payload['message'] ?? '平台账单暂时无法读取'));
}
$summary = $payload['summary'] ?? null;
if (! is_array($summary)
|| ! array_key_exists('opening_balance', $summary)
|| ! is_numeric($summary['income'] ?? null)
|| ! is_numeric($summary['expense'] ?? null)
) {
throw new RuntimeException('平台账单汇总数据不完整');
}
return [
'opening_balance' => is_numeric($summary['opening_balance'])
? round((float) $summary['opening_balance'], 2)
: null,
'income' => round((float) $summary['income'], 2),
'expense' => round((float) $summary['expense'], 2),
];
}
private function safeErrorMessage(Throwable $exception): string
{
$message = trim($exception->getMessage());
return match ($message) {
'平台期初余额未就绪' => $message,
'平台账单汇总数据不完整' => $message,
'invalid_balance_overview_period' => '日期范围无效',
'unsupported_balance_overview_platform' => '平台类型无效',
'' => '平台账单暂时无法读取',
default => mb_substr($message, 0, 160),
};
}
}