CaiWuXiTong/modules/EcommerceFinance/Repositories/JdPromotionAccountBalanceRepository.php
2026-09-14 11:40:06 +08:00

184 lines
8.0 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
class JdPromotionAccountBalanceRepository
{
public const DATASET_KEY = 'js_data_middle_platform.dbo.jd_jzt_finance_records';
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function usesBusinessMirror(): bool
{
try {
return $this->mirror->ready($this->dataset());
} catch (Throwable $exception) {
report($exception);
return false;
}
}
/** @return string[] */
public function storeOptions(): array
{
$this->ensureReady();
return $this->connection->table($this->dataset()->targetTable())
->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->distinct()
->orderBy('shop_name')
->pluck('shop_name')
->map(static fn (mixed $value): string => (string) $value)
->all();
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateMonthlyBalances(
?string $storeName = null,
?string $month = null,
int $page = 1,
int $pageSize = 25,
): array {
$this->ensureReady();
$month = $this->normalizeMonth($month);
$page = max($page, 1);
$pageSize = min(max($pageSize, 1), 5000);
$driver = $this->connection->getDriverName();
$monthStart = $driver === 'sqlite'
? "date(src.show_date, 'start of month')"
: "DATE_FORMAT(src.show_date, '%Y-%m-01')";
$currentMonth = CarbonImmutable::now()->startOfMonth()->toDateString();
$entryTime = 'COALESCE(src.settle_datetime, src.show_date)';
$ranked = $this->connection->table($this->dataset()->targetTable() . ' AS src')
->whereNotNull('src.shop_name')
->where('src.shop_name', '<>', '')
->whereNotNull('src.show_date')
->where('src.show_date', '<', $currentMonth)
->when($storeName !== null && $storeName !== '', fn (Builder $query) => $query->where('src.shop_name', $storeName))
->selectRaw("src.shop_name, src.show_date AS entry_date, src.balance_amount AS entry_balance, $monthStart AS month_start")
->selectRaw("CASE WHEN src.in_out_type = 'IN' OR src.in_out_type_name = '入账' THEN src.amount ELSE 0 END AS income_value")
->selectRaw("CASE WHEN src.in_out_type = 'OUT' OR src.in_out_type_name = '出账' THEN src.amount ELSE 0 END AS expense_value")
->selectRaw("LAG(src.balance_amount) OVER (PARTITION BY src.shop_name ORDER BY src.show_date, $entryTime, src.id) AS previous_balance")
->selectRaw("LAG(src.show_date) OVER (PARTITION BY src.shop_name ORDER BY src.show_date, $entryTime, src.id) AS previous_date")
->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shop_name, $monthStart ORDER BY src.show_date, $entryTime, src.id) AS month_first_rank")
->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shop_name, $monthStart ORDER BY src.show_date DESC, $entryTime DESC, src.id DESC) AS month_last_rank");
$groups = $this->connection->query()->fromSub($ranked, 'ranked')
->when($month !== null, fn (Builder $query) => $query->where('month_start', $month . '-01'))
->selectRaw('shop_name, month_start, COALESCE(SUM(income_value), 0) AS income_total, COALESCE(SUM(expense_value), 0) AS expense_total')
->selectRaw('MAX(CASE WHEN month_first_rank = 1 THEN previous_balance END) AS opening_balance')
->selectRaw('MAX(CASE WHEN month_first_rank = 1 THEN previous_date END) AS opening_source_date')
->selectRaw('MAX(CASE WHEN month_last_rank = 1 THEN entry_balance END) AS system_closing_balance')
->selectRaw('MAX(CASE WHEN month_last_rank = 1 THEN entry_date END) AS closing_source_date')
->groupBy('shop_name')
->groupBy('month_start');
$total = $this->connection->query()->fromSub(clone $groups, 'monthly_count')->count();
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
$rows = $this->connection->query()->fromSub($groups, 'monthly_balances')
->orderByDesc('month_start')
->orderBy('shop_name')
->forPage($page, $pageSize)
->get()
->map(fn (object $row): array => $this->normalizeRow($row))
->all();
return ['rows' => $rows, 'total' => $total];
}
/** @return array<string,mixed> */
private function normalizeRow(object $row): array
{
$monthStart = CarbonImmutable::parse((string) $row->month_start)->startOfMonth();
$expectedOpeningDate = $monthStart->subMonth()->endOfMonth()->toDateString();
$expectedClosingDate = $monthStart->endOfMonth()->toDateString();
$openingSourceDate = $row->opening_source_date === null
? null
: CarbonImmutable::parse((string) $row->opening_source_date)->toDateString();
$closingSourceDate = $row->closing_source_date === null
? null
: CarbonImmutable::parse((string) $row->closing_source_date)->toDateString();
$openingBalance = $openingSourceDate === $expectedOpeningDate
&& $row->opening_balance !== null
? round((float) $row->opening_balance, 2)
: null;
$income = round((float) $row->income_total, 2);
$expense = round((float) $row->expense_total, 2);
$systemClosing = $closingSourceDate !== $expectedClosingDate || $row->system_closing_balance === null
? null
: round((float) $row->system_closing_balance, 2);
$closing = $openingBalance === null ? null : round($openingBalance + $income - $expense, 2);
$difference = $closing === null || $systemClosing === null
? null
: round($systemClosing - $closing, 2);
$verified = $difference !== null && abs($difference) < 0.005;
if ($openingBalance === null && $systemClosing === null) {
$reason = '缺少期初余额和系统期末余额';
} elseif ($openingBalance === null) {
$reason = '缺少上月期末余额';
} elseif ($systemClosing === null) {
$reason = '缺少本月系统期末余额';
} elseif ($verified) {
$reason = '';
} else {
$reason = sprintf('系统期末余额与计算期末余额相差 %.2f 元', abs($difference));
}
return [
'date' => $expectedClosingDate,
'store_name' => (string) $row->shop_name,
'opening_balance' => $openingBalance,
'income_total' => $income,
'expense_total' => $expense,
'closing_balance' => $closing,
'system_closing_balance' => $systemClosing,
'balance_difference' => $difference,
'difference_reason' => $reason,
'verification_status' => $verified ? '已核对' : '待核对',
'notes' => '',
];
}
private function normalizeMonth(?string $month): ?string
{
return $month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1
? $month
: null;
}
private function ensureReady(): void
{
if (! $this->usesBusinessMirror()) {
throw new RuntimeException('京东账户余额共享业务镜像尚未完成同步。');
}
}
private function dataset(): BusinessMirrorDataset
{
return $this->datasets->get(self::DATASET_KEY);
}
}