397 lines
16 KiB
PHP
397 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Repositories;
|
|
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
|
use RuntimeException;
|
|
|
|
/** 店铺收款账单·淘系支付宝账户只读经营分析副本镜像。 */
|
|
class StoreReceivingAlipayBillMirrorRepository
|
|
{
|
|
public const DATASET = 'operations_replica.rpt.jst_originalBillInfo';
|
|
public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo';
|
|
|
|
public function __construct(
|
|
private readonly BusinessMirrorDatasetRegistry $datasets,
|
|
private readonly GenericBusinessMirrorRepository $mirror,
|
|
) {}
|
|
|
|
/** @return array{data:array<int,array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array<string,mixed>} */
|
|
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
|
|
{
|
|
return $this->paginateChannel($filters, $sort, $direction, $page, $perPage, null, 'taobao');
|
|
}
|
|
|
|
/**
|
|
* Read one upstream platform channel from the shared original-bill mirror.
|
|
* The mirror is shared by several account pages; callers must provide a
|
|
* fixed source channel rather than accepting arbitrary SQL/filter input.
|
|
*
|
|
* @return array{data:array<int,array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array<string,mixed>}
|
|
*/
|
|
public function paginateChannel(
|
|
array $filters,
|
|
string $sort,
|
|
string $direction,
|
|
int $page,
|
|
int $perPage,
|
|
?string $channel,
|
|
string $mapping = 'taobao',
|
|
): array
|
|
{
|
|
$dataset = $this->datasets->get(self::DATASET);
|
|
if (! $this->mirror->ready($dataset)
|
|
|| ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) {
|
|
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
|
|
}
|
|
|
|
$page = max($page, 1);
|
|
$perPage = min(max($perPage, 1), 100);
|
|
$direction = $direction === 'asc' ? 'asc' : 'desc';
|
|
$sortColumn = [
|
|
'record_code' => 'b.Id',
|
|
'occurred_at' => 'b.fee_time',
|
|
'income' => 'b.amount',
|
|
'expense' => 'b.amount',
|
|
'balance' => 'b.amount',
|
|
][$sort] ?? 'b.fee_time';
|
|
|
|
$query = $this->billQuery();
|
|
$this->applyChannel($query, $channel);
|
|
$this->applyFilters($query, $filters);
|
|
$state = $this->mirror->state($dataset) ?? [];
|
|
// The synchronizer persists an exact COUNT in the same transaction that
|
|
// marks a run successful. Never substitute a database row estimate.
|
|
$hasFilters = collect($filters)->contains(static fn (mixed $value): bool => $value !== null && trim((string) $value) !== '');
|
|
$recordedCount = $state['mirror_row_count'] ?? null;
|
|
$useRecordedCount = $channel === null && ! $hasFilters && ($state['status'] ?? null) === 'succeeded'
|
|
&& $recordedCount !== null && ctype_digit((string) $recordedCount);
|
|
$total = $useRecordedCount ? (int) $recordedCount : (clone $query)->count();
|
|
$summary = $this->summary($query, $filters, $channel);
|
|
|
|
$billRows = $query
|
|
->select('b.*')
|
|
->orderBy($sortColumn, $direction)
|
|
->orderBy('b.Id', $direction)
|
|
->forPage($page, $perPage)
|
|
->get()
|
|
->all();
|
|
$shopNames = $this->shopNames($billRows);
|
|
$rows = collect($billRows)
|
|
->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping))
|
|
->all();
|
|
$income = $summary['income'];
|
|
$expense = $summary['expense'];
|
|
|
|
return [
|
|
'data' => $rows,
|
|
'total' => $total,
|
|
'current_page' => $page,
|
|
'last_page' => max((int) ceil($total / $perPage), 1),
|
|
'per_page' => $perPage,
|
|
'summary' => [
|
|
'income' => $income,
|
|
'expense' => $expense,
|
|
'balance' => $income - $expense,
|
|
'count' => $total,
|
|
],
|
|
'sync' => [
|
|
'status' => $state['status'] ?? 'unknown',
|
|
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
|
|
],
|
|
];
|
|
}
|
|
|
|
/** @return iterable<int,array<string,mixed>> */
|
|
public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable
|
|
{
|
|
return $this->exportChannelRows($filters, $sort, $direction, null, 'taobao');
|
|
}
|
|
|
|
/** @return iterable<int,array<string,mixed>> */
|
|
public function exportChannelRows(
|
|
array $filters,
|
|
string $sort = 'occurred_at',
|
|
string $direction = 'asc',
|
|
?string $channel = null,
|
|
string $mapping = 'taobao',
|
|
): iterable
|
|
{
|
|
$dataset = $this->datasets->get(self::DATASET);
|
|
if (! $this->mirror->ready($dataset)
|
|
|| ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) {
|
|
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
|
|
}
|
|
|
|
$query = $this->billQuery();
|
|
$this->applyChannel($query, $channel);
|
|
$this->applyFilters($query, $filters);
|
|
$shopNames = $this->allShopNames();
|
|
$direction = $direction === 'desc' ? 'desc' : 'asc';
|
|
$sortColumn = [
|
|
'record_code' => 'b.Id',
|
|
'occurred_at' => 'b.fee_time',
|
|
'income' => 'b.amount',
|
|
'expense' => 'b.amount',
|
|
'balance' => 'b.amount',
|
|
][$sort] ?? 'b.fee_time';
|
|
|
|
return $query->select('b.*')
|
|
->orderBy($sortColumn, $direction)->orderBy('b.Id', $direction)->cursor()
|
|
->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping));
|
|
}
|
|
|
|
private function billQuery(): Builder
|
|
{
|
|
return $this->mirror->connection()->table('ops_jst_original_bill_info as b');
|
|
}
|
|
|
|
/** @return array{income:float,expense:float} */
|
|
private function summary(Builder $billQuery, array $filters, ?string $channel = null): array
|
|
{
|
|
if ($channel === null && $this->dailySummarySupports($filters)) {
|
|
$summary = $this->dailySummary($filters);
|
|
if ($summary !== null) {
|
|
return $summary;
|
|
}
|
|
}
|
|
|
|
$row = (clone $billQuery)->selectRaw(
|
|
"COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, "
|
|
. "COALESCE(SUM(CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS expense"
|
|
)->first();
|
|
|
|
return [
|
|
'income' => round((float) ($row->income ?? 0), 2),
|
|
'expense' => round((float) ($row->expense ?? 0), 2),
|
|
];
|
|
}
|
|
|
|
private function dailySummarySupports(array $filters): bool
|
|
{
|
|
return ($filters['alipay_account'] ?? '') === ''
|
|
&& ($filters['bill_item'] ?? '') === ''
|
|
&& ($filters['keyword'] ?? '') === '';
|
|
}
|
|
|
|
/** @return array{income:float,expense:float}|null */
|
|
private function dailySummary(array $filters): ?array
|
|
{
|
|
$connection = $this->mirror->connection();
|
|
$schema = Schema::connection($connection->getName());
|
|
if (! $schema->hasTable('store_report_import_states') || ! $schema->hasTable('store_report_daily_rows')) {
|
|
return null;
|
|
}
|
|
|
|
$state = $connection->table('store_report_import_states')
|
|
->where('dataset_key', 'store-report.daily-sales')->first();
|
|
$generation = trim((string) ($state->active_generation ?? ''));
|
|
if ($generation === '' || empty($state->initial_completed_at)) {
|
|
return null;
|
|
}
|
|
|
|
$query = $connection->table('store_report_daily_rows')->where('generation', $generation);
|
|
if (($filters['store_name'] ?? '') !== '') {
|
|
$query->where('store_name', $filters['store_name']);
|
|
}
|
|
if (($filters['start'] ?? '') !== '') {
|
|
$query->where('occurred_on', '>=', $filters['start']);
|
|
}
|
|
if (($filters['end'] ?? '') !== '') {
|
|
$query->where('occurred_on', '<=', $filters['end']);
|
|
}
|
|
$row = $query->selectRaw('COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first();
|
|
$income = round((float) ($row->income ?? 0), 2);
|
|
$expense = round((float) ($row->expense ?? 0), 2);
|
|
|
|
return match ($this->directionCode((string) ($filters['business_type'] ?? ''))) {
|
|
'1' => ['income' => $income, 'expense' => 0.0],
|
|
'-1' => ['income' => 0.0, 'expense' => $expense],
|
|
default => ['income' => $income, 'expense' => $expense],
|
|
};
|
|
}
|
|
|
|
private function applyFilters(Builder $query, array $filters): void
|
|
{
|
|
if (($filters['store_name'] ?? '') !== '') {
|
|
$shopIds = $this->mirror->connection()->table('ops_jst_shop_info')
|
|
->where('shop_name', $filters['store_name'])
|
|
->pluck('shop_id')
|
|
->filter(static fn (mixed $value): bool => $value !== null && $value !== '')
|
|
->all();
|
|
if ($shopIds === []) {
|
|
$query->whereRaw('1 = 0');
|
|
} else {
|
|
$query->whereIn('b.shop_id', $shopIds);
|
|
}
|
|
}
|
|
if (($filters['alipay_account'] ?? '') !== '') {
|
|
$query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['alipay_account']) . '%');
|
|
}
|
|
if (($filters['bill_item'] ?? '') !== '') {
|
|
$query->where('b.fee_name', $filters['bill_item']);
|
|
}
|
|
if (($filters['business_type'] ?? '') !== '') {
|
|
$query->where('b.inout_type', $this->directionCode((string) $filters['business_type']));
|
|
}
|
|
if (($filters['start'] ?? '') !== '') {
|
|
$query->where('b.fee_time', '>=', $filters['start'] . ' 00:00:00');
|
|
}
|
|
if (($filters['end'] ?? '') !== '') {
|
|
$query->where('b.fee_time', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00');
|
|
}
|
|
if (($filters['keyword'] ?? '') !== '') {
|
|
$value = '%' . $this->escapeLike((string) $filters['keyword']) . '%';
|
|
$query->where(function (Builder $query) use ($value): void {
|
|
foreach (['b.fee_name', 'b.primary_key', 'b.p_auto_id', 'b.so_id', 'b.sku_id', 'b.shop_sku_id', 'b.shop_i_id', 'b.channel'] as $column) {
|
|
$query->orWhere($column, 'like', $value);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private function applyChannel(Builder $query, ?string $channel): void
|
|
{
|
|
if ($channel !== null && $channel !== '') {
|
|
$query->where('b.channel', $channel);
|
|
}
|
|
}
|
|
|
|
/** @param array<int,object> $rows @return array<string,string> */
|
|
private function shopNames(array $rows): array
|
|
{
|
|
$shopIds = collect($rows)->pluck('shop_id')->filter()->unique()->values()->all();
|
|
if ($shopIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return $this->mirror->connection()->table('ops_jst_shop_info')
|
|
->whereIn('shop_id', $shopIds)->pluck('shop_name', 'shop_id')
|
|
->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name])
|
|
->all();
|
|
}
|
|
|
|
/** @return array<string,string> */
|
|
private function allShopNames(): array
|
|
{
|
|
return $this->mirror->connection()->table('ops_jst_shop_info')
|
|
->whereNotNull('shop_id')->pluck('shop_name', 'shop_id')
|
|
->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name])
|
|
->all();
|
|
}
|
|
|
|
private function mapRow(object $row, ?string $shopName): array
|
|
{
|
|
$amount = abs((float) ($row->amount ?? 0));
|
|
$income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0;
|
|
$expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0;
|
|
|
|
return [
|
|
'id' => (string) $row->Id,
|
|
'record_code' => (string) $row->Id,
|
|
'store_name' => $shopName,
|
|
'custom_remark' => null,
|
|
'bill_item' => $row->fee_name,
|
|
'alipay_account' => $row->channel,
|
|
'account_order_no' => $row->primary_key,
|
|
'business_serial_no' => $row->auto_id,
|
|
'merchant_order_no' => $row->p_auto_id,
|
|
'business_base_order_no' => $row->so_id,
|
|
'goods_name' => $row->sku_id ?: $row->shop_sku_id,
|
|
'counterparty_account' => null,
|
|
'occurred_at' => $this->dateTime($row->fee_time),
|
|
'income' => $income,
|
|
'expense' => $expense,
|
|
'balance' => $income - $expense,
|
|
'business_type' => $this->directionName($row->inout_type),
|
|
'remark' => null,
|
|
'online_order_no' => $row->so_id,
|
|
'online_real_store' => $shopName,
|
|
'business_description' => $row->combine_type,
|
|
'business_bill_source' => $row->channel,
|
|
'business_order_no' => $row->p_auto_id,
|
|
'created_at' => $this->dateTime($row->createTime),
|
|
'updated_at' => $this->dateTime($row->updateTime),
|
|
'modifier' => '',
|
|
];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function mapRowFor(object $row, ?string $shopName, string $mapping): array
|
|
{
|
|
if ($mapping !== 'douyin') {
|
|
return $this->mapRow($row, $shopName);
|
|
}
|
|
|
|
$amount = abs((float) ($row->amount ?? 0));
|
|
$income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0;
|
|
$expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0;
|
|
$direction = $this->directionName($row->inout_type);
|
|
|
|
return [
|
|
'id' => (string) $row->Id,
|
|
'record_code' => (string) $row->Id,
|
|
'store_name' => $shopName,
|
|
'finance_remark' => null,
|
|
'order_no' => $row->so_id ?: $row->p_auto_id,
|
|
'amount' => $amount,
|
|
'custom_remark' => null,
|
|
'bill_item' => $row->fee_name,
|
|
'occurred_at' => $this->dateTime($row->fee_time),
|
|
'income' => $income,
|
|
'expense' => $expense,
|
|
'balance' => $income - $expense,
|
|
'remark' => null,
|
|
'modifier' => '聚水潭',
|
|
'created_at' => $this->dateTime($row->createTime),
|
|
'updated_at' => $this->dateTime($row->updateTime),
|
|
'payload' => [
|
|
'merchant_name' => $shopName,
|
|
'move_serial_no' => $row->auto_id ?: $row->primary_key,
|
|
'order_no_2' => $row->p_auto_id ?: $row->so_id,
|
|
'online_order_no' => $row->so_id,
|
|
'sub_order_no' => $row->shop_i_id,
|
|
'goods_id' => $row->sku_id ?: $row->shop_sku_id,
|
|
'account_type' => $direction,
|
|
'move_summary' => $row->fee_name,
|
|
'order_placed_at' => $this->dateTime($row->createTime),
|
|
'business_type' => $row->combine_type,
|
|
'settle_account' => $row->channel,
|
|
'move_scene' => '头条放心购',
|
|
'talent_id' => null,
|
|
'talent_name' => null,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function directionCode(string $value): string
|
|
{
|
|
return match ($value) {
|
|
'收入', 'IN', 'in' => '1',
|
|
'支出', 'OUT', 'out' => '-1',
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
private function directionName(mixed $value): string
|
|
{
|
|
return match ((string) $value) {
|
|
'1' => '收入',
|
|
'-1' => '支出',
|
|
default => (string) ($value ?? ''),
|
|
};
|
|
}
|
|
|
|
private function dateTime(mixed $value): string
|
|
{
|
|
return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value));
|
|
}
|
|
|
|
private function escapeLike(string $value): string
|
|
{
|
|
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
|
|
}
|
|
}
|