209 lines
7.4 KiB
PHP
209 lines
7.4 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Repositories;
|
||
|
||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||
|
||
/**
|
||
* Alibaba/1688 营销账户账单。
|
||
*
|
||
* 数据来源:JianSun.dbo.alibaba_bill。金额字段由上游按元存储。
|
||
*/
|
||
class AlibabaBillRepository
|
||
{
|
||
private const TABLE = '[dbo].[alibaba_bill]';
|
||
private const SOURCE = 'jiansun';
|
||
|
||
private UpstreamReadOnlyQuery $query;
|
||
|
||
public function __construct(
|
||
UpstreamReadOnlyQueryFactory $factory,
|
||
private readonly ?MarketingAccountBillMirrorRepository $mirror = null,
|
||
) {
|
||
$this->query = $factory->forSource(self::SOURCE);
|
||
}
|
||
|
||
public function usesBusinessMirror(): bool
|
||
{
|
||
return $this->mirror?->ready(MarketingAccountBillMirrorRepository::ALIBABA) ?? false;
|
||
}
|
||
|
||
/** @return array{shops: string[], product_types: string[]} */
|
||
public function filterOptions(): array
|
||
{
|
||
if ($this->usesBusinessMirror()) {
|
||
return $this->mirror->alibabaFilterOptions();
|
||
}
|
||
|
||
$shops = $this->query->select(
|
||
'SELECT DISTINCT TOP (500) shopName FROM ' . self::TABLE
|
||
. ' WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> \'\' ORDER BY shopName'
|
||
);
|
||
$types = $this->query->select(
|
||
'SELECT DISTINCT TOP (500) tradeTypeName FROM ' . self::TABLE
|
||
. ' WHERE tradeTypeName IS NOT NULL AND LTRIM(RTRIM(tradeTypeName)) <> \'\' ORDER BY tradeTypeName'
|
||
);
|
||
|
||
return [
|
||
'shops' => array_values(array_map(static fn (array $row): string => (string) ($row['shopName'] ?? ''), $shops)),
|
||
'product_types' => array_values(array_map(static fn (array $row): string => (string) ($row['tradeTypeName'] ?? ''), $types)),
|
||
];
|
||
}
|
||
|
||
/** @return array{rows: array<int,array<string,mixed>>, total: int} */
|
||
public function paginate(
|
||
?string $shopName = null,
|
||
?string $productType = null,
|
||
?string $fromDate = null,
|
||
?string $toDate = null,
|
||
int $page = 1,
|
||
int $pageSize = 25,
|
||
): array {
|
||
$page = max(1, $page);
|
||
$pageSize = min(max(1, $pageSize), 100);
|
||
|
||
if ($this->usesBusinessMirror()) {
|
||
$result = $this->mirror->paginateAlibaba(
|
||
$shopName,
|
||
$productType,
|
||
$fromDate,
|
||
$toDate,
|
||
$page,
|
||
$pageSize,
|
||
);
|
||
|
||
return [
|
||
'rows' => array_map(fn (array $row): array => $this->normalize($row), $result['rows']),
|
||
'total' => $result['total'],
|
||
];
|
||
}
|
||
|
||
[$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate);
|
||
$total = (int) $this->query->scalar('SELECT COUNT(*) FROM ' . self::TABLE . ' src' . $where, $bindings);
|
||
|
||
if ($total === 0) {
|
||
return ['rows' => [], 'total' => 0];
|
||
}
|
||
|
||
$start = (($page - 1) * $pageSize) + 1;
|
||
$end = $page * $pageSize;
|
||
$sql = 'SELECT * FROM ('
|
||
. 'SELECT src.id, src.shopName, src.tradeDay, src.tradeTypeName, '
|
||
. 'src.tradeAMTIn, src.tradeAMTOut, '
|
||
. 'ROW_NUMBER() OVER (ORDER BY src.tradeDay DESC, src.id DESC) AS rn '
|
||
. 'FROM ' . self::TABLE . ' src' . $where
|
||
. ') AS t WHERE t.rn BETWEEN ? AND ? ORDER BY t.rn';
|
||
|
||
$rows = $this->query->select($sql, array_merge($bindings, [$start, $end]));
|
||
|
||
return [
|
||
'rows' => array_map(fn (array $row): array => $this->normalize($row), $rows),
|
||
'total' => $total,
|
||
];
|
||
}
|
||
|
||
/** @return \Generator<int,array<string,mixed>> */
|
||
public function exportRows(
|
||
?string $shopName = null,
|
||
?string $productType = null,
|
||
?string $fromDate = null,
|
||
?string $toDate = null,
|
||
int $batchSize = 100,
|
||
): \Generator {
|
||
if (! $this->usesBusinessMirror()) {
|
||
throw new \RuntimeException('阿里巴巴账单共享业务镜像尚未完成同步。');
|
||
}
|
||
|
||
$batchSize = max(1, min(100, $batchSize));
|
||
$page = 1;
|
||
$exported = 0;
|
||
|
||
do {
|
||
$result = $this->mirror->paginateAlibaba(
|
||
$shopName,
|
||
$productType,
|
||
$fromDate,
|
||
$toDate,
|
||
$page,
|
||
$batchSize,
|
||
);
|
||
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : [];
|
||
$total = max(0, (int) ($result['total'] ?? 0));
|
||
|
||
foreach ($rows as $row) {
|
||
yield $this->normalize($row);
|
||
}
|
||
|
||
$count = count($rows);
|
||
$exported += $count;
|
||
$page++;
|
||
} while ($count > 0 && $exported < $total);
|
||
}
|
||
|
||
/** @return array{income_total: float, expense_total: float, net: float, count: int} */
|
||
public function summary(
|
||
?string $shopName = null,
|
||
?string $productType = null,
|
||
?string $fromDate = null,
|
||
?string $toDate = null,
|
||
): array {
|
||
if ($this->usesBusinessMirror()) {
|
||
$row = $this->mirror->summarizeAlibaba($shopName, $productType, $fromDate, $toDate);
|
||
} else {
|
||
[$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate);
|
||
$row = $this->query->first(
|
||
'SELECT COALESCE(SUM(tradeAMTIn), 0) AS income_total, '
|
||
. 'COALESCE(SUM(tradeAMTOut), 0) AS expense_total, COUNT(*) AS cnt '
|
||
. 'FROM ' . self::TABLE . ' src' . $where,
|
||
$bindings,
|
||
) ?? [];
|
||
}
|
||
$income = round((float) ($row['income_total'] ?? 0), 2);
|
||
$expense = round((float) ($row['expense_total'] ?? 0), 2);
|
||
|
||
return [
|
||
'income_total' => $income,
|
||
'expense_total' => $expense,
|
||
'net' => round($income - $expense, 2),
|
||
'count' => (int) ($row['cnt'] ?? 0),
|
||
];
|
||
}
|
||
|
||
/** @return array{0: string, 1: array<int,mixed>} */
|
||
private function where(?string $shopName, ?string $productType, ?string $fromDate, ?string $toDate): array
|
||
{
|
||
$conditions = [];
|
||
$bindings = [];
|
||
if ($shopName !== null && $shopName !== '') {
|
||
$conditions[] = 'src.shopName = ?';
|
||
$bindings[] = $shopName;
|
||
}
|
||
if ($productType !== null && $productType !== '') {
|
||
$conditions[] = 'src.tradeTypeName = ?';
|
||
$bindings[] = $productType;
|
||
}
|
||
if ($fromDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fromDate) === 1) {
|
||
$conditions[] = 'CAST(src.tradeDay AS date) >= CAST(? AS date)';
|
||
$bindings[] = $fromDate;
|
||
}
|
||
if ($toDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $toDate) === 1) {
|
||
$conditions[] = 'CAST(src.tradeDay AS date) <= CAST(? AS date)';
|
||
$bindings[] = $toDate;
|
||
}
|
||
|
||
return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $bindings];
|
||
}
|
||
|
||
private function normalize(array $row): array
|
||
{
|
||
return [
|
||
'shop_name' => (string) ($row['shopName'] ?? ''),
|
||
'trade_day' => (string) ($row['tradeDay'] ?? ''),
|
||
'product_type' => (string) ($row['tradeTypeName'] ?? ''),
|
||
'income_amount' => round((float) ($row['tradeAMTIn'] ?? 0), 2),
|
||
'expense_amount' => round((float) ($row['tradeAMTOut'] ?? 0), 2),
|
||
];
|
||
}
|
||
}
|