184 lines
8.0 KiB
PHP
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);
|
|
}
|
|
}
|