CaiWuXiTong/modules/EcommerceFinance/Repositories/TaobaoAlipaySettlementBillRepository.php
qiuxiang 40d3ce162a feat: 淘系微信账单增加淘系支付宝账户余额 (#898)
淘系微信账户新增期初余额与淘系支付宝期末余额。

测试:7 tests / 41 assertions;前端生产构建通过。
2026-11-05 18:36:23 +08:00

360 lines
15 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 TaobaoAlipaySettlementBillRepository
{
private const IMPORT_TABLE = 'taobao_alipay_bill_imports';
private const ROW_TABLE = 'taobao_alipay_bill_rows';
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 array{shops:list<string>,bill_items:list<string>} */
public function accountBillFilterOptions(): array
{
$query = $this->query();
return [
'shops' => (clone $query)->whereNotNull('store_name')->where('store_name', '<>', '')
->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all(),
'bill_items' => (clone $query)->whereNotNull('bill_item')->where('bill_item', '<>', '')
->distinct()->orderBy('bill_item')->pluck('bill_item')->map('strval')->all(),
];
}
/** @return array<string,mixed> */
public function paginate(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->applyFilters($query, $filters);
$summary = $this->summaryForQuery($query, $filters);
$total = $summary['count'];
$sortColumn = $this->sortColumn($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->mapRow($row))->all();
return [
'data' => $rows,
'total' => $total,
'current_page' => $page,
'last_page' => max(1, (int) ceil($total / $perPage)),
'per_page' => $perPage,
'summary' => $summary,
'sync' => [
'status' => 'succeeded',
'last_succeeded_at' => $this->connection->table(self::IMPORT_TABLE)
->where('status', 'succeeded')->max('completed_at'),
],
];
}
/**
* 当前筛选条件的账户余额汇总。
*
* 淘系微信页签本身没有余额列,直接复用这里的期初、收支和期末口径,
* 避免两个页签对同一账期计算出不同的账户余额。
*
* @return array{income:float,expense:float,opening_balance:float,opening_balance_date:?string,opening_balance_source:string,balance:float,count:int}
*/
public function summary(array $filters): array
{
$query = $this->query();
$this->applyFilters($query, $filters);
return $this->summaryForQuery($query, $filters);
}
/**
* @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 = (float) ($row->income_total ?? 0);
$expense = (float) ($row->expense_total ?? 0);
$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 exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable
{
$query = $this->query();
$this->applyFilters($query, $filters);
$sortColumn = $this->sortColumn($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->mapRow($row));
}
private function query(): Builder
{
if (! $this->ready()) {
throw new RuntimeException('taobao_alipay_authoritative_bills_not_ready');
}
return $this->connection->table(self::ROW_TABLE);
}
/** @param array<string,mixed> $filters */
private function applyFilters(Builder $query, array $filters): void
{
$store = trim((string) ($filters['store_name'] ?? ''));
if ($store !== '') $query->where('store_name', $store);
$channel = trim((string) ($filters['source_channel'] ?? ''));
if ($channel !== '') $query->where('business_bill_source', 'like', '%' . $this->escapeLike($channel) . '%');
$item = trim((string) ($filters['bill_item'] ?? ''));
if ($item !== '') $query->where('bill_item', $item);
// 「总账备注」:淘系支付宝页签同页混有天猫/淘宝/阿里巴巴店铺,按店铺名前缀分平台过滤。
$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 . '%'),
);
} else {
LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'bill_item');
}
$type = trim((string) ($filters['business_type'] ?? ''));
if ($type === '收入') $query->where('income_amount', '>', 0);
if ($type === '支出') $query->where('expense_amount', '>', 0);
$from = $this->date($filters['start'] ?? null);
if ($from !== null) $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00');
$to = $this->date($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 !== '') {
$like = '%' . $this->escapeLike($keyword) . '%';
$query->where(function (Builder $nested) use ($like): void {
foreach ([
'record_code', 'account_order_no', 'business_serial_no', 'merchant_order_no',
'business_base_order_no', 'online_order_no', 'business_order_no', 'remark',
'custom_remark', 'business_description', 'bill_item', 'goods_name',
] as $column) {
$method = $column === 'record_code' ? 'where' : 'orWhere';
$nested->{$method}($column, 'like', $like);
}
});
}
}
private function sortColumn(string $sort): string
{
return [
'occurred_at' => 'occurred_at',
'income' => 'income_amount',
'expense' => 'expense_amount',
'balance' => 'balance_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($filters['start'] ?? null)
?? $this->date($filters['end'] ?? null);
if ($start === null) {
return ['balance' => 0.0, 'date' => null, 'source' => 'none'];
}
$monthStart = $start->modify('first day of this month');
$nextMonthStart = $monthStart->modify('+1 month');
$cutoff = $monthStart->format('Y-m-d') . ' 00:00:00';
$periodEnd = $nextMonthStart->format('Y-m-d') . ' 00:00:00';
$store = trim((string) ($filters['store_name'] ?? ''));
$importVersion = (string) ($this->connection->table(self::IMPORT_TABLE)
->where('status', 'succeeded')
->max('completed_at') ?? '');
$cacheKey = 'taobao-alipay-opening-balance:v1:' . hash('sha256', implode('|', [
$cutoff,
$store,
$importVersion,
]));
$opening = Cache::remember(
$cacheKey,
self::OPENING_BALANCE_CACHE_SECONDS,
function () use ($cutoff, $periodEnd, $store): array {
$ranked = $this->connection->table(self::ROW_TABLE)
->where('occurred_at', '<', $cutoff)
->whereNotNull('alipay_account')
->where('alipay_account', '<>', '')
->when($store !== '', fn (Builder $query) => $query->where('store_name', $store))
->select([
'alipay_account',
'occurred_at',
'source_row_number',
'income_amount',
'expense_amount',
'balance_amount',
])
->selectRaw(
'ROW_NUMBER() OVER (' .
'PARTITION BY alipay_account ' .
'ORDER BY occurred_at DESC, source_row_number DESC' .
') AS row_rank',
);
$latestRows = $this->connection->query()
->fromSub($ranked, 'latest_balances')
->where('row_rank', 1)
->get();
if ($latestRows->isNotEmpty()) {
return [
'balance' => round((float) $latestRows->sum('balance_amount'), 2),
'source' => 'previous_period',
];
}
// 首个导入月份没有上月快照时,用当月首笔余额倒推期初:
// 期初 = 首笔余额 - 首笔收入 + 首笔支出。
$firstRows = $this->connection->table(self::ROW_TABLE)
->where('occurred_at', '>=', $cutoff)
->where('occurred_at', '<', $periodEnd)
->whereNotNull('alipay_account')
->where('alipay_account', '<>', '')
->when($store !== '', fn (Builder $query) => $query->where('store_name', $store))
->select([
'alipay_account',
'occurred_at',
'source_row_number',
'income_amount',
'expense_amount',
'balance_amount',
])
->selectRaw(
'ROW_NUMBER() OVER (' .
'PARTITION BY alipay_account ' .
'ORDER BY occurred_at ASC, source_row_number ASC' .
') AS row_rank',
);
$first = $this->connection->query()
->fromSub($firstRows, 'first_balances')
->where('row_rank', 1)
->get();
return [
'balance' => round((float) $first->sum(
static fn (object $row): float => (float) $row->balance_amount
- (float) $row->income_amount
+ (float) $row->expense_amount,
), 2),
'source' => 'first_row_estimate',
];
},
);
return [
'balance' => (float) $opening['balance'],
'date' => $monthStart->modify('-1 day')->format('Y-m-d'),
'source' => (string) $opening['source'],
];
}
/** @return array<string,mixed> */
private function mapRow(object $row): array
{
return [
'id' => (string) $row->record_code,
'record_code' => (string) $row->record_code,
'store_name' => (string) ($row->store_name ?? ''),
'custom_remark' => (string) ($row->custom_remark ?? ''),
'bill_item' => (string) ($row->bill_item ?? ''),
'alipay_account' => (string) ($row->alipay_account ?? ''),
'account_order_no' => (string) ($row->account_order_no ?? ''),
'business_serial_no' => (string) ($row->business_serial_no ?? ''),
'merchant_order_no' => (string) ($row->merchant_order_no ?? ''),
'business_base_order_no' => (string) ($row->business_base_order_no ?? ''),
'goods_name' => (string) ($row->goods_name ?? ''),
'counterparty_account' => (string) ($row->counterparty_account ?? ''),
'occurred_at' => (string) $row->occurred_at,
'income' => (float) $row->income_amount,
'expense' => (float) $row->expense_amount,
'balance' => (float) $row->balance_amount,
'business_type' => (string) ($row->business_type ?? ''),
'remark' => (string) ($row->remark ?? ''),
'online_order_no' => (string) ($row->online_order_no ?? ''),
'online_real_store' => (string) ($row->online_real_store ?? ''),
'business_description' => (string) ($row->business_description ?? ''),
'business_bill_source' => (string) ($row->business_bill_source ?? ''),
'business_order_no' => (string) ($row->business_order_no ?? ''),
'created_at' => $row->source_created_at !== null ? (string) $row->source_created_at : null,
'updated_at' => $row->source_updated_at !== null ? (string) $row->source_updated_at : null,
'modifier' => (string) ($row->source_modified_by ?? ''),
];
}
private function date(mixed $value): ?DateTimeImmutable
{
$value = trim((string) ($value ?? ''));
if ($value === '' || 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;
}
private function escapeLike(string $value): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
}
}