376 lines
15 KiB
PHP
376 lines
15 KiB
PHP
<?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),
|
||
};
|
||
}
|
||
}
|