418 lines
16 KiB
PHP
418 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Repositories;
|
|
|
|
use DateTimeImmutable;
|
|
use Illuminate\Database\ConnectionInterface;
|
|
use Illuminate\Database\DatabaseManager;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
|
|
use RuntimeException;
|
|
|
|
class PinduoduoSettlementBillRepository
|
|
{
|
|
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
|
|
private const ROW_TABLE = 'pdd_settlement_bill_rows';
|
|
private const BALANCE_SNAPSHOT_TABLE = 'pdd_platform_balance_snapshots';
|
|
private const OPENING_BALANCE_CACHE_SECONDS = 300;
|
|
|
|
private readonly ConnectionInterface $connection;
|
|
|
|
public function __construct(DatabaseManager $databases)
|
|
{
|
|
$this->connection = $databases->connection(
|
|
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
|
);
|
|
}
|
|
|
|
public function ready(): bool
|
|
{
|
|
$schema = Schema::connection($this->connection->getName());
|
|
if (! $schema->hasTable(self::IMPORT_TABLE) || ! $schema->hasTable(self::ROW_TABLE)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->connection->table(self::IMPORT_TABLE)->where('status', 'succeeded')->exists();
|
|
}
|
|
|
|
/** @return string[] */
|
|
public function storeOptions(): array
|
|
{
|
|
return $this->query()->whereNotNull('store_name')->where('store_name', '<>', '')
|
|
->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all();
|
|
}
|
|
|
|
/** @return array{shops:list<string>,bill_items:list<string>} */
|
|
public function accountBillFilterOptions(): array
|
|
{
|
|
$shops = $this->query()->whereNotNull('store_name')->where('store_name', '<>', '')
|
|
->distinct()->orderBy('store_name')->pluck('store_name')
|
|
->map(static fn (mixed $value): string => (string) $value)->all();
|
|
$billItems = $this->query()->whereNotNull('bill_item')->where('bill_item', '<>', '')
|
|
->distinct()->orderBy('bill_item')->pluck('bill_item')
|
|
->map(static fn (mixed $value): string => (string) $value)->all();
|
|
|
|
return ['shops' => $shops, 'bill_items' => $billItems];
|
|
}
|
|
|
|
/** @return array{rows:list<array<string,mixed>>,total:int} */
|
|
public function paginate(
|
|
?string $storeName,
|
|
?string $fromDate,
|
|
?string $toDate,
|
|
?string $flowType,
|
|
int $page,
|
|
int $pageSize,
|
|
): array {
|
|
$page = max(1, $page);
|
|
$pageSize = min(max(1, $pageSize), 500);
|
|
$query = $this->query();
|
|
$this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType);
|
|
$total = (clone $query)->count();
|
|
|
|
$rows = $query->orderByDesc('occurred_at')->orderByDesc('record_code')
|
|
->forPage($page, $pageSize)->get()->map(function (object $row): array {
|
|
$signed = (float) $row->signed_amount;
|
|
$income = (float) $row->income_amount;
|
|
$expense = (float) $row->expense_amount;
|
|
$flowType = $income > 0 ? 1 : ($expense > 0 ? 2 : 0);
|
|
|
|
return [
|
|
'id' => $row->record_code,
|
|
'shopName' => (string) ($row->store_name ?? ''),
|
|
'amount' => abs($signed),
|
|
'balance' => null,
|
|
'summary' => (string) ($row->bill_item ?? ''),
|
|
'flowType' => $flowType,
|
|
'toAdvertiserFundType' => null,
|
|
'createTime' => (string) $row->occurred_at,
|
|
'source_primary_key' => (string) ($row->merchant_order_no ?? ''),
|
|
'source_record_code' => (string) $row->record_code,
|
|
'source_channel' => 'PinduoduoSettlementBill',
|
|
];
|
|
})->all();
|
|
|
|
return ['rows' => $rows, 'total' => $total];
|
|
}
|
|
|
|
/** @return array{income_total:float,expense_total:float,cnt:int} */
|
|
public function summarize(
|
|
?string $storeName,
|
|
?string $fromDate,
|
|
?string $toDate,
|
|
?string $flowType,
|
|
): array {
|
|
$query = $this->query();
|
|
$this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType);
|
|
$row = $query->selectRaw(
|
|
'COALESCE(SUM(income_amount), 0) AS income_total, '
|
|
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS cnt'
|
|
)->first();
|
|
|
|
return [
|
|
'income_total' => (float) ($row->income_total ?? 0),
|
|
'expense_total' => (float) ($row->expense_total ?? 0),
|
|
'cnt' => (int) ($row->cnt ?? 0),
|
|
];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function paginateAccountBills(
|
|
array $filters,
|
|
string $sort,
|
|
string $direction,
|
|
int $page,
|
|
int $perPage,
|
|
): array {
|
|
$page = max(1, $page);
|
|
$perPage = min(max(1, $perPage), 100);
|
|
$query = $this->query();
|
|
$this->applyAccountBillFilters($query, $filters);
|
|
|
|
$summary = $this->summaryForQuery($query, $filters);
|
|
$total = $summary['count'];
|
|
|
|
$sortColumn = $this->accountBillSortColumn($sort);
|
|
$direction = $direction === 'asc' ? 'asc' : 'desc';
|
|
|
|
$query->orderBy($sortColumn, $direction);
|
|
if ($sortColumn !== 'record_code') {
|
|
$query->orderBy('record_code', $direction);
|
|
}
|
|
|
|
$rows = $query->forPage($page, $perPage)->get()
|
|
->map(fn (object $row): array => $this->mapAccountBillRow($row))->all();
|
|
|
|
return [
|
|
'data' => $rows,
|
|
'total' => $total,
|
|
'current_page' => $page,
|
|
'last_page' => max(1, (int) ceil($total / $perPage)),
|
|
'per_page' => $perPage,
|
|
'from' => $total === 0 ? null : (($page - 1) * $perPage) + 1,
|
|
'to' => $total === 0 ? null : min($page * $perPage, $total),
|
|
'summary' => $summary,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* income:float,expense:float,opening_balance:float,opening_balance_date:?string,
|
|
* opening_balance_source:string,balance:float,count:int
|
|
* }
|
|
*/
|
|
private function summaryForQuery(Builder $query, array $filters): array
|
|
{
|
|
$row = (clone $query)->selectRaw(
|
|
'COALESCE(SUM(income_amount), 0) AS income_total, '
|
|
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count'
|
|
)->first();
|
|
$income = round((float) ($row->income_total ?? 0), 2);
|
|
$expense = round((float) ($row->expense_total ?? 0), 2);
|
|
$opening = $this->openingBalance($filters);
|
|
|
|
return [
|
|
'income' => $income,
|
|
'expense' => $expense,
|
|
'opening_balance' => $opening['balance'],
|
|
'opening_balance_date' => $opening['date'],
|
|
'opening_balance_source' => $opening['source'],
|
|
'balance' => round($opening['balance'] + $income - $expense, 2),
|
|
'count' => (int) ($row->row_count ?? 0),
|
|
];
|
|
}
|
|
|
|
/** @return iterable<int,array<string,mixed>> */
|
|
public function exportAccountBills(array $filters, string $sort, string $direction): iterable
|
|
{
|
|
$query = $this->query();
|
|
$this->applyAccountBillFilters($query, $filters);
|
|
$sortColumn = $this->accountBillSortColumn($sort);
|
|
$direction = $direction === 'desc' ? 'desc' : 'asc';
|
|
$query->orderBy($sortColumn, $direction);
|
|
if ($sortColumn !== 'record_code') {
|
|
$query->orderBy('record_code', $direction);
|
|
}
|
|
|
|
return $query->cursor()->map(fn (object $row): array => $this->mapAccountBillRow($row));
|
|
}
|
|
|
|
private function query(): Builder
|
|
{
|
|
if (! $this->ready()) {
|
|
throw new RuntimeException('pinduoduo_authoritative_bills_not_ready');
|
|
}
|
|
|
|
return $this->connection->table(self::ROW_TABLE);
|
|
}
|
|
|
|
private function applyFilters(
|
|
Builder $query,
|
|
?string $storeName,
|
|
?string $fromDate,
|
|
?string $toDate,
|
|
?string $flowType,
|
|
): void {
|
|
if ($storeName !== null && $storeName !== '') {
|
|
$query->where('store_name', $storeName);
|
|
}
|
|
$from = $this->date($fromDate);
|
|
if ($from !== null) {
|
|
$query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00');
|
|
}
|
|
$to = $this->date($toDate);
|
|
if ($to !== null) {
|
|
$query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00');
|
|
}
|
|
if ($flowType === 'income') {
|
|
$query->where('income_amount', '>', 0);
|
|
} elseif ($flowType === 'expense') {
|
|
$query->where('expense_amount', '>', 0);
|
|
}
|
|
}
|
|
/** @param array<string,mixed> $filters */
|
|
private function applyAccountBillFilters(Builder $query, array $filters): void
|
|
{
|
|
$storeName = trim((string) ($filters['store_name'] ?? ''));
|
|
if ($storeName !== '') {
|
|
$query->where('store_name', $storeName);
|
|
}
|
|
|
|
$billItem = trim((string) ($filters['bill_item'] ?? ''));
|
|
if ($billItem !== '') {
|
|
$query->where('bill_item', $billItem);
|
|
}
|
|
|
|
$this->applyLedgerRemarkFilter($query, $filters);
|
|
|
|
$from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null);
|
|
if ($from !== null) {
|
|
$query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00');
|
|
}
|
|
|
|
$to = $this->date(isset($filters['end']) ? (string) $filters['end'] : null);
|
|
if ($to !== null) {
|
|
$query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00');
|
|
}
|
|
|
|
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
|
if ($keyword !== '') {
|
|
$query->where(function (Builder $nested) use ($keyword): void {
|
|
$like = '%' . $keyword . '%';
|
|
$nested->where('record_code', 'like', $like)
|
|
->orWhere('merchant_order_no', 'like', $like)
|
|
->orWhere('online_order_no', 'like', $like)
|
|
->orWhere('remark', 'like', $like)
|
|
->orWhere('custom_remark', 'like', $like)
|
|
->orWhere('business_description', 'like', $like)
|
|
->orWhere('bill_item', 'like', $like);
|
|
});
|
|
}
|
|
}
|
|
|
|
/** 「总账备注」筛选:翻译成账单项目集合(拼多多单平台)。 */
|
|
private function applyLedgerRemarkFilter(Builder $query, array $filters): void
|
|
{
|
|
$groups = is_array($filters['bill_item_groups'] ?? null) ? $filters['bill_item_groups'] : [];
|
|
if ($groups !== []) {
|
|
LedgerRemarkFilter::applyGroups(
|
|
$query,
|
|
$groups,
|
|
'bill_item',
|
|
static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'),
|
|
static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'bill_item');
|
|
}
|
|
|
|
private function accountBillSortColumn(string $sort): string
|
|
{
|
|
return [
|
|
'occurred_at' => 'occurred_at',
|
|
'income' => 'income_amount',
|
|
'expense' => 'expense_amount',
|
|
'record_code' => 'record_code',
|
|
][$sort] ?? 'occurred_at';
|
|
}
|
|
|
|
/**
|
|
* 以最近一次平台实际期末余额为锚点,滚动计算所选账期的期初余额。
|
|
*
|
|
* @return array{balance:float,date:?string,source:string}
|
|
*/
|
|
private function openingBalance(array $filters): array
|
|
{
|
|
$start = $this->date(isset($filters['start']) ? (string) $filters['start'] : null)
|
|
?? $this->date(isset($filters['end']) ? (string) $filters['end'] : null);
|
|
if ($start === null) {
|
|
return ['balance' => 0.0, 'date' => null, 'source' => 'none'];
|
|
}
|
|
|
|
$monthStart = $start->modify('first day of this month')->setTime(0, 0);
|
|
$balanceDate = $this->connection->table(self::BALANCE_SNAPSHOT_TABLE)
|
|
->where('period_end', '<', $monthStart->format('Y-m-d'))
|
|
->orderBy('period_end')
|
|
->value('period_end');
|
|
if ($balanceDate === null) {
|
|
return ['balance' => 0.0, 'date' => null, 'source' => 'none'];
|
|
}
|
|
|
|
$store = trim((string) ($filters['store_name'] ?? ''));
|
|
$snapshotVersion = (string) ($this->connection->table(self::BALANCE_SNAPSHOT_TABLE)
|
|
->max('imported_at') ?? '');
|
|
$importVersion = (string) ($this->connection->table(self::IMPORT_TABLE)
|
|
->where('status', 'succeeded')
|
|
->max('updated_at') ?? '');
|
|
$cacheKey = 'pdd-opening-balance:v1:' . hash('sha256', implode('|', [
|
|
$monthStart->format('Y-m-d'),
|
|
$balanceDate,
|
|
$store,
|
|
$snapshotVersion,
|
|
$importVersion,
|
|
]));
|
|
|
|
$balance = Cache::remember(
|
|
$cacheKey,
|
|
self::OPENING_BALANCE_CACHE_SECONDS,
|
|
function () use ($monthStart, $balanceDate, $store): float {
|
|
$baseline = $this->connection->table(self::BALANCE_SNAPSHOT_TABLE)
|
|
->where('period_end', $balanceDate)
|
|
->when(
|
|
$store !== '',
|
|
fn (Builder $query) => $query->where(
|
|
'normalized_store_name',
|
|
JushuitanShopBalanceRepository::normalizeStoreName($store),
|
|
),
|
|
)
|
|
->sum('balance');
|
|
|
|
$movement = $this->connection->table(self::ROW_TABLE)
|
|
->where('occurred_at', '>=', $balanceDate . ' 00:00:00')
|
|
->where('occurred_at', '<', $monthStart->format('Y-m-d') . ' 00:00:00')
|
|
->when($store !== '', fn (Builder $query) => $query->where('store_name', $store))
|
|
->selectRaw('COALESCE(SUM(income_amount - expense_amount), 0) AS net_total')
|
|
->first();
|
|
|
|
return round((float) $baseline + (float) ($movement->net_total ?? 0), 2);
|
|
},
|
|
);
|
|
|
|
$date = $monthStart->modify('-1 day')->format('Y-m-d');
|
|
|
|
return [
|
|
'balance' => (float) $balance,
|
|
'date' => $date,
|
|
'source' => $date === $balanceDate ? 'platform_snapshot' : 'previous_period',
|
|
];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function mapAccountBillRow(object $row): array
|
|
{
|
|
$signedAmount = (float) $row->signed_amount;
|
|
|
|
return [
|
|
'id' => (string) $row->record_code,
|
|
'record_code' => (string) $row->record_code,
|
|
'store_name' => (string) ($row->store_name ?? ''),
|
|
'finance_remark' => (string) ($row->business_description ?? ''),
|
|
'order_no' => (string) ($row->merchant_order_no ?? ''),
|
|
'amount' => abs($signedAmount),
|
|
'custom_remark' => (string) ($row->custom_remark ?? ''),
|
|
'bill_item' => (string) ($row->bill_item ?? ''),
|
|
'occurred_at' => (string) $row->occurred_at,
|
|
'income' => (float) $row->income_amount,
|
|
'expense' => (float) $row->expense_amount,
|
|
'balance' => $signedAmount,
|
|
'remark' => (string) ($row->remark ?? ''),
|
|
'updated_at' => $row->source_updated_at !== null
|
|
? (string) $row->source_updated_at
|
|
: null,
|
|
'modifier' => (string) ($row->source_modified_by ?? ''),
|
|
'payload' => [
|
|
'merchant_order_no' => (string) ($row->merchant_order_no ?? ''),
|
|
'online_order_no' => (string) ($row->online_order_no ?? ''),
|
|
'finance_type' => (string) ($row->business_type ?? ''),
|
|
'business_description' => (string) ($row->business_description ?? ''),
|
|
'type' => (string) ($row->account_name ?? ''),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function date(?string $value): ?DateTimeImmutable
|
|
{
|
|
if ($value === null || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
|
return null;
|
|
}
|
|
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
|
|
|
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
|
|
}
|
|
}
|