560 lines
20 KiB
PHP
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);
|
|
}
|
|
}
|