CaiWuXiTong/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php
WuYongJie 43445fde4b feat: read marketing bills from business mirror
首次全量完成后,营销账户账单、余额、汇总、选项及分页优先读取 caiwu_business_mirror;未就绪时保留旧快照链路。
2026-08-14 17:48:23 +08:00

560 lines
20 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
/**
* 营销账户账单的完整业务镜像读取层。
*
* 这里只返回与旧 SQL Server Repository 相同的原始字段;金额换算和业务名称
* 仍由原 Repository 负责,确保镜像切换不会改变页面口径。
*/
class MarketingAccountBillMirrorRepository
{
public const TAOBAO = 'jiansun.dbo.tmall_spread_bills';
public const PINDUODUO = 'jiansun.dbo.pdd_spread_bills';
public const ALIBABA = 'jiansun.dbo.alibaba_bill';
public const JD_RECHARGE = 'jiansun.dbo.jd_spreadBills';
public const JD_CONSUMPTION = 'jiansun.dbo.jd_costBills';
private readonly ConnectionInterface $connection;
/** @var array<string,bool> */
private array $readiness = [];
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
public function ready(string $datasetKey): bool
{
if (array_key_exists($datasetKey, $this->readiness)) {
return $this->readiness[$datasetKey];
}
try {
return $this->readiness[$datasetKey] = $this->mirror->ready($this->dataset($datasetKey));
} catch (Throwable $exception) {
report($exception);
return $this->readiness[$datasetKey] = false;
}
}
/** @return string[] */
public function taobaoStoreOptions(): array
{
return $this->stringOptions(self::TAOBAO, 'shopName');
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateTaobao(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
int $page,
int $pageSize,
): array {
$query = $this->table(self::TAOBAO, 'src');
$this->applyTaobaoFilters($query, $storeName, $fromDate, $toDate, $flowType);
return $this->paginate($query, [
'src.id', 'src.shopName', 'src.uniTransTime', 'src.transTime', 'src.amount',
'src.balance', 'src.comment', 'src.finType', 'src.bizType', 'src.tradeType',
], ['src.uniTransTime', 'src.id'], $page, min(max($pageSize, 1), 200));
}
/** @return array{income_total_raw:float,expense_total_raw:float,cnt:int} */
public function summarizeTaobao(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): array {
$query = $this->table(self::TAOBAO, 'src');
$this->applyTaobaoFilters($query, $storeName, $fromDate, $toDate, $flowType);
$row = $query->selectRaw(
"COALESCE(SUM(CASE WHEN src.finType = 'DEBIT' THEN src.amount ELSE 0 END), 0) AS income_total_raw, "
. "COALESCE(SUM(CASE WHEN src.finType = 'CREDIT' THEN src.amount ELSE 0 END), 0) AS expense_total_raw, "
. 'COUNT(*) AS cnt'
)->first();
return [
'income_total_raw' => (float) ($row->income_total_raw ?? 0),
'expense_total_raw' => (float) ($row->expense_total_raw ?? 0),
'cnt' => (int) ($row->cnt ?? 0),
];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateTaobaoMonthlyBalances(
?string $storeName,
?string $month,
int $page,
int $pageSize,
): array {
return $this->monthlyBalances(
self::TAOBAO,
'transTime',
"CASE WHEN src.finType = 'DEBIT' THEN src.amount ELSE 0 END",
"CASE WHEN src.finType = 'CREDIT' THEN src.amount ELSE 0 END",
$storeName,
$month,
$page,
min(max($pageSize, 1), 5000),
'opening_balance_raw',
'income_total_raw',
'expense_total_raw',
'system_closing_balance_raw',
);
}
/** @return string[] */
public function pinduoduoStoreOptions(): array
{
return $this->stringOptions(self::PINDUODUO, 'shopName');
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginatePinduoduo(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
int $page,
int $pageSize,
): array {
$query = $this->table(self::PINDUODUO, 'src');
$this->applyPinduoduoFilters($query, $storeName, $fromDate, $toDate, $flowType);
return $this->paginate($query, [
'src.id', 'src.shopName', 'src.amount', 'src.balance', 'src.summary',
'src.flowType', 'src.toAdvertiserFundType', 'src.createTime',
], ['src.createTime', 'src.id'], $page, min(max($pageSize, 1), 200));
}
/** @return array{income_total:float,expense_total:float,cnt:int} */
public function summarizePinduoduo(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): array {
$query = $this->table(self::PINDUODUO, 'src');
$this->applyPinduoduoFilters($query, $storeName, $fromDate, $toDate, $flowType);
$row = $query->selectRaw(
'COALESCE(SUM(CASE WHEN src.flowType = 1 THEN src.amount ELSE 0 END), 0) AS income_total, '
. 'COALESCE(SUM(CASE WHEN src.flowType = 2 THEN src.amount ELSE 0 END), 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{rows:array<int,array<string,mixed>>,total:int} */
public function paginatePinduoduoMonthlyBalances(
?string $storeName,
?string $month,
int $page,
int $pageSize,
): array {
return $this->monthlyBalances(
self::PINDUODUO,
'createTime',
'CASE WHEN src.flowType = 1 THEN src.amount ELSE 0 END',
'CASE WHEN src.flowType = 2 THEN src.amount ELSE 0 END',
$storeName,
$month,
$page,
min(max($pageSize, 1), 5000),
'opening_balance',
'income_total',
'expense_total',
'system_closing_balance',
);
}
/** @return array{shops:string[],product_types:string[]} */
public function alibabaFilterOptions(): array
{
return [
'shops' => $this->stringOptions(self::ALIBABA, 'shopName', 500),
'product_types' => $this->stringOptions(self::ALIBABA, 'tradeTypeName', 500),
];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateAlibaba(
?string $shopName,
?string $productType,
?string $fromDate,
?string $toDate,
int $page,
int $pageSize,
): array {
$query = $this->table(self::ALIBABA, 'src');
$this->applyAlibabaFilters($query, $shopName, $productType, $fromDate, $toDate);
return $this->paginate($query, [
'src.id', 'src.shopName', 'src.tradeDay', 'src.tradeTypeName',
'src.tradeAMTIn', 'src.tradeAMTOut',
], ['src.tradeDay', 'src.id'], $page, min(max($pageSize, 1), 100));
}
/** @return array{income_total:float,expense_total:float,cnt:int} */
public function summarizeAlibaba(
?string $shopName,
?string $productType,
?string $fromDate,
?string $toDate,
): array {
$query = $this->table(self::ALIBABA, 'src');
$this->applyAlibabaFilters($query, $shopName, $productType, $fromDate, $toDate);
$row = $query->selectRaw(
'COALESCE(SUM(src.tradeAMTIn), 0) AS income_total, '
. 'COALESCE(SUM(src.tradeAMTOut), 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 string[] */
public function jdShopOptions(): array
{
$shops = array_merge(
$this->stringOptions(self::JD_RECHARGE, 'shopName', 500),
$this->stringOptions(self::JD_CONSUMPTION, 'shopName', 500),
);
$shops = array_values(array_unique($shops));
sort($shops, SORT_NATURAL);
return array_slice($shops, 0, 500);
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateJdRecharge(
?string $shopName,
?string $fromDate,
?string $toDate,
int $page,
int $pageSize,
): array {
return $this->paginateJd(
self::JD_RECHARGE,
'createDate',
['src.id', 'src.shopName', 'src.amount', 'src.balanceAmount', 'src.orderTypeName',
'src.pk', 'src.createDate', 'src.updateTime', 'src.statusName', 'src.remark'],
$shopName,
$fromDate,
$toDate,
$page,
$pageSize,
);
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
public function paginateJdConsumption(
?string $shopName,
?string $fromDate,
?string $toDate,
int $page,
int $pageSize,
): array {
return $this->paginateJd(
self::JD_CONSUMPTION,
'creatTime',
['src.id', 'src.shopName', 'src.amount', 'src.balanceAmount', 'src.orderTypeName',
'src.creatTime', 'src.settleTime', 'src.pk', 'src.invoiceApplyTypeName'],
$shopName,
$fromDate,
$toDate,
$page,
$pageSize,
);
}
/** @return array{amount_total:float,balance_total:float,cnt:int} */
public function summarizeJd(
string $datasetKey,
string $dateField,
?string $shopName,
?string $fromDate,
?string $toDate,
): array {
$query = $this->table($datasetKey, 'src');
$this->applyJdFilters($query, $dateField, $shopName, $fromDate, $toDate);
$row = $query->selectRaw(
'COALESCE(SUM(ABS(src.amount)), 0) AS amount_total, '
. 'COALESCE(SUM(src.balanceAmount), 0) AS balance_total, COUNT(*) AS cnt'
)->first();
return [
'amount_total' => (float) ($row->amount_total ?? 0),
'balance_total' => (float) ($row->balance_total ?? 0),
'cnt' => (int) ($row->cnt ?? 0),
];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
private function paginateJd(
string $datasetKey,
string $dateField,
array $fields,
?string $shopName,
?string $fromDate,
?string $toDate,
int $page,
int $pageSize,
): array {
$query = $this->table($datasetKey, 'src');
$this->applyJdFilters($query, $dateField, $shopName, $fromDate, $toDate);
return $this->paginate(
$query,
$fields,
['src.' . $dateField, 'src.id'],
$page,
min(max($pageSize, 1), 100),
);
}
private function applyTaobaoFilters(
Builder $query,
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): void {
$this->whereValue($query, 'src.shopName', $storeName);
$this->whereFromDate($query, 'src.transTime', $fromDate);
$this->whereToDate($query, 'src.transTime', $toDate);
if ($flowType === 'income') {
$query->where('src.finType', 'DEBIT');
} elseif ($flowType === 'expense') {
$query->where('src.finType', 'CREDIT');
}
}
private function applyPinduoduoFilters(
Builder $query,
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): void {
$this->whereValue($query, 'src.shopName', $storeName);
$this->whereFromDate($query, 'src.createTime', $fromDate, true);
$this->whereToDate($query, 'src.createTime', $toDate);
if ($flowType === 'income') {
$query->where('src.flowType', 1);
} elseif ($flowType === 'expense') {
$query->where('src.flowType', 2);
}
}
private function applyAlibabaFilters(
Builder $query,
?string $shopName,
?string $productType,
?string $fromDate,
?string $toDate,
): void {
$this->whereValue($query, 'src.shopName', $shopName);
$this->whereValue($query, 'src.tradeTypeName', $productType);
if ($this->validDate($fromDate)) {
$query->whereDate('src.tradeDay', '>=', $fromDate);
}
if ($this->validDate($toDate)) {
$query->whereDate('src.tradeDay', '<=', $toDate);
}
}
private function applyJdFilters(
Builder $query,
string $dateField,
?string $shopName,
?string $fromDate,
?string $toDate,
): void {
$this->whereValue($query, 'src.shopName', $shopName);
$this->whereFromDate($query, 'src.' . $dateField, $fromDate, true);
$this->whereToDate($query, 'src.' . $dateField, $toDate);
}
private function whereValue(Builder $query, string $column, ?string $value): void
{
if ($value !== null && $value !== '') {
$query->where($column, $value);
}
}
private function whereFromDate(Builder $query, string $column, ?string $date, bool $midnight = false): void
{
if ($this->validDate($date)) {
$query->where($column, '>=', $date . ($midnight ? ' 00:00:00' : ''));
}
}
private function whereToDate(Builder $query, string $column, ?string $date): void
{
if ($this->validDate($date)) {
$query->where($column, '<', date('Y-m-d', strtotime($date . ' +1 day')) . ' 00:00:00');
}
}
private function validDate(?string $date): bool
{
return $date !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1;
}
/** @return string[] */
private function stringOptions(string $datasetKey, string $column, ?int $limit = null): array
{
$query = $this->table($datasetKey)
->whereNotNull($column)
->where($column, '<>', '')
->distinct()
->orderBy($column);
if ($limit !== null) {
$query->limit($limit);
}
return $query->pluck($column)->map(static fn (mixed $value): string => (string) $value)->all();
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
private function paginate(Builder $query, array $fields, array $orderColumns, int $page, int $pageSize): array
{
$page = max($page, 1);
$total = (clone $query)->count();
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
foreach ($orderColumns as $column) {
$query->orderByDesc($column);
}
$rows = $query->select($fields)
->forPage($page, $pageSize)
->get()
->map(static fn (object $row): array => (array) $row)
->all();
return ['rows' => $rows, 'total' => $total];
}
/** @return array{rows:array<int,array<string,mixed>>,total:int} */
private function monthlyBalances(
string $datasetKey,
string $dateField,
string $incomeExpression,
string $expenseExpression,
?string $storeName,
?string $month,
int $page,
int $pageSize,
string $openingAlias,
string $incomeAlias,
string $expenseAlias,
string $closingAlias,
): array {
$page = max($page, 1);
$driver = $this->connection->getDriverName();
$monthStart = $driver === 'sqlite'
? "date(src.$dateField, 'start of month')"
: "DATE_FORMAT(src.$dateField, '%Y-%m-01')";
$currentMonth = $driver === 'sqlite'
? "date('now', 'start of month')"
: "DATE_FORMAT(CURRENT_DATE, '%Y-%m-01')";
$nextMonth = $driver === 'sqlite'
? "date(m.month_start, '+1 month')"
: 'DATE_ADD(m.month_start, INTERVAL 1 MONTH)';
$lastDay = $driver === 'sqlite'
? "date(m.month_start, '+1 month', '-1 day')"
: "DATE_FORMAT(LAST_DAY(m.month_start), '%Y-%m-%d')";
$table = $this->dataset($datasetKey)->targetTable();
$groups = $this->connection->table($table . ' AS src')
->whereNotNull('src.shopName')
->whereNotNull('src.' . $dateField)
->whereRaw("src.$dateField < $currentMonth")
->when($storeName !== null && $storeName !== '', fn (Builder $query) => $query->where('src.shopName', $storeName))
->when($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1, function (Builder $query) use ($dateField, $month): void {
$start = $month . '-01';
$end = date('Y-m-d', strtotime($start . ' +1 month'));
$query->where('src.' . $dateField, '>=', $start)->where('src.' . $dateField, '<', $end);
})
->selectRaw("src.shopName, $monthStart AS month_start, COALESCE(SUM($incomeExpression), 0) AS $incomeAlias, COALESCE(SUM($expenseExpression), 0) AS $expenseAlias")
->groupBy('src.shopName')
->groupByRaw($monthStart);
$total = $this->connection->query()->fromSub(clone $groups, 'monthly_count')->count();
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
$query = $this->connection->query()->fromSub($groups, 'm')
->selectRaw("m.shopName, $lastDay AS balance_date, m.$incomeAlias, m.$expenseAlias")
->selectSub(function (Builder $query) use ($table, $dateField): void {
$query->from($table . ' AS opening_entry')
->select('opening_entry.balance')
->whereColumn('opening_entry.shopName', 'm.shopName')
->whereColumn('opening_entry.' . $dateField, '<', 'm.month_start')
->orderByDesc('opening_entry.' . $dateField)
->orderByDesc('opening_entry.id')
->limit(1);
}, $openingAlias)
->selectSub(function (Builder $query) use ($table, $dateField, $nextMonth): void {
$query->from($table . ' AS closing_entry')
->select('closing_entry.balance')
->whereColumn('closing_entry.shopName', 'm.shopName')
->whereRaw("closing_entry.$dateField < $nextMonth")
->orderByDesc('closing_entry.' . $dateField)
->orderByDesc('closing_entry.id')
->limit(1);
}, $closingAlias)
->orderByDesc('m.month_start')
->orderBy('m.shopName')
->forPage($page, $pageSize);
return [
'rows' => $query->get()->map(static fn (object $row): array => (array) $row)->all(),
'total' => $total,
];
}
private function table(string $datasetKey, ?string $alias = null): Builder
{
if (! $this->ready($datasetKey)) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
$table = $this->dataset($datasetKey)->targetTable();
return $this->connection->table($alias === null ? $table : $table . ' AS ' . $alias);
}
private function dataset(string $datasetKey): BusinessMirrorDataset
{
return $this->datasets->get($datasetKey);
}
}