1277 lines
50 KiB
PHP
1277 lines
50 KiB
PHP
<?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\TaobaoAlipaySettlementBillRepository;
|
||
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);
|
||
}
|
||
|
||
$rowsByStore = [];
|
||
foreach ($rows as $row) {
|
||
$storeName = trim((string) ($row['store_name'] ?? ''));
|
||
$storeName = $storeName !== '' ? $storeName : '未标注店铺';
|
||
$rowsByStore[$storeName] ??= [
|
||
'opening_balance' => null,
|
||
'income' => 0.0,
|
||
'expense' => 0.0,
|
||
];
|
||
if (is_numeric($row['opening_balance'] ?? null)) {
|
||
$rowsByStore[$storeName]['opening_balance'] = round(
|
||
(float) ($rowsByStore[$storeName]['opening_balance'] ?? 0)
|
||
+ (float) $row['opening_balance'],
|
||
2,
|
||
);
|
||
}
|
||
$rowsByStore[$storeName]['income'] = round(
|
||
(float) $rowsByStore[$storeName]['income'] + (float) ($row['income'] ?? 0),
|
||
2,
|
||
);
|
||
$rowsByStore[$storeName]['expense'] = round(
|
||
(float) $rowsByStore[$storeName]['expense'] + (float) ($row['expense'] ?? 0),
|
||
2,
|
||
);
|
||
}
|
||
|
||
$platformRows = [];
|
||
foreach ($rowsByStore as $storeName => $row) {
|
||
$opening = $row['opening_balance'];
|
||
$income = $row['income'];
|
||
$expense = $row['expense'];
|
||
$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'),
|
||
);
|
||
$opening = app(TaobaoAlipaySettlementBillRepository::class)
|
||
->storeOpeningBalances($start);
|
||
|
||
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),
|
||
};
|
||
}
|
||
}
|