CaiWuXiTong/modules/EcommerceFinance/Services/StoreReceivingBalanceOverviewService.php

491 lines
19 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\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\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 RuntimeException;
use Throwable;
class StoreReceivingBalanceOverviewService
{
/** @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 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,
];
}
/**
* 收入合计的平台、店铺、账单项目明细。
*
* 各平台仍沿用对应账单页的同一数据源和账期口径;这里只汇总收入行,
* 明细金额统一返回元,页面再按万元展示。
*
* @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,
];
}
/** @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 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),
};
}
}