CaiWuXiTong/modules/EcommerceFinance/Services/StoreReceivingBalanceOverviewService.php

376 lines
15 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 $month): array
{
[$periodStart, $periodEnd] = $this->periodForMonth($month);
$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' => $month,
'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 $month): array
{
[$periodStart, $periodEnd] = $this->periodForMonth($month);
$platformOrder = [];
$details = [];
$totalIncome = 0.0;
foreach (self::PLATFORMS as $index => $definition) {
$platformOrder[$definition['slug']] = $index;
$buckets = [];
try {
foreach ($this->fetchPlatformIncomeRows(
$definition['slug'],
$periodStart,
$periodEnd,
) as $row) {
$income = round((float) ($row['income'] ?? 0), 2);
if ($income <= 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) + $income,
2,
);
$totalIncome += $income;
}
} catch (Throwable $exception) {
throw new RuntimeException('收入明细暂时无法读取,请稍后重试。', 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' => $month,
'period' => [
'start' => $periodStart,
'end' => $periodEnd,
],
'summary' => [
'income' => round($totalIncome, 2),
'platform_count' => count(self::PLATFORMS),
'available_platform_count' => count(self::PLATFORMS),
'complete' => true,
],
'details' => $details,
];
}
/** @return array{0:string,1:string} */
private function periodForMonth(string $month): array
{
$start = CarbonImmutable::createFromFormat('!Y-m', $month, 'Asia/Shanghai');
if (! $start instanceof CarbonImmutable || $start->format('Y-m') !== $month) {
throw new RuntimeException('invalid_balance_overview_month');
}
return [
$start->startOfMonth()->format('Y-m-d'),
$start->endOfMonth()->format('Y-m-d'),
];
}
/**
* 返回某平台所选月份的收入行。各仓库的导出游标复用账单页现有筛选口径,
* 避免为明细页复制一套容易漂移的查询。
*
* @return iterable<int,array{store_name?:mixed,bill_item?:mixed,income?:mixed}>
*/
protected function fetchPlatformIncomeRows(string $platform, string $start, string $end): iterable
{
$filters = [
'start' => $start,
'end' => $end,
'business_type' => '收入',
];
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->accountBillIncomeRows(
$platform,
$start,
$end,
),
default => throw new RuntimeException('unsupported_balance_overview_platform'),
};
}
/**
* 本系统落库账户按对应账单页相同的账期规则读取收入:
* 唯品会按 payload.settle_period,小红书等按 occurred_at。
*
* @return iterable<int,array{store_name:string,bill_item:string,income:float}>
*/
private function accountBillIncomeRows(string $platform, string $start, string $end): iterable
{
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $platform)
->where('income', '>', 0);
if (in_array($platform, ['vipshop', 'vipshop-jx'], true)) {
$query->whereRaw(
"JSON_UNQUOTE(JSON_EXTRACT(payload, '$.settle_period')) = ?",
[substr($start, 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,
];
}
}
/**
* @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_month' => '月份格式无效',
'unsupported_balance_overview_platform' => '平台类型无效',
'' => '平台账单暂时无法读取',
default => mb_substr($message, 0, 160),
};
}
}