- 所有账户页签在「店铺」右侧新增「总账备注」下拉:按(平台+账单项目)翻译成账单项目集合过滤,页面/列设置/导出同口径;淘系支付宝、淘系微信同页签混多平台时按店铺名前缀细分 - 每日账单脚本:胜算超出同步导出上限(url=null+taskId)时自动从「异步导出下载管理」取回本次任务全部分片并合并成一份 CSV,导入前按页面笔数核对(不一致则跳过导入) - 实测:京东 7 月异步导出两片 219158+235846=455004 笔 / 收入 17695094.84 / 支出 8035128.21,与已导入口径一致
217 lines
9.5 KiB
PHP
217 lines
9.5 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\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 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 = (clone $query)->selectRaw(
|
|
'COALESCE(SUM(income_amount), 0) AS income_total, '
|
|
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count'
|
|
)->first();
|
|
$total = (int) ($summary->row_count ?? 0);
|
|
$income = (float) ($summary->income_total ?? 0);
|
|
$expense = (float) ($summary->expense_total ?? 0);
|
|
$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' => [
|
|
'income' => $income,
|
|
'expense' => $expense,
|
|
'balance' => round($income - $expense, 2),
|
|
'count' => $total,
|
|
],
|
|
'sync' => [
|
|
'status' => 'succeeded',
|
|
'last_succeeded_at' => $this->connection->table(self::IMPORT_TABLE)
|
|
->where('status', 'succeeded')->max('completed_at'),
|
|
],
|
|
];
|
|
}
|
|
|
|
/** @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<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);
|
|
}
|
|
}
|