*/ 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>,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>,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>,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>,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>,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>,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>,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>,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>,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>,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')"; $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(); $ranked = $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)) ->selectRaw("src.shopName, src.$dateField AS entry_time, src.id AS entry_id, src.balance AS entry_balance, $monthStart AS month_start") ->selectRaw("$incomeExpression AS income_value, $expenseExpression AS expense_value") ->selectRaw("LAG(src.balance) OVER (PARTITION BY src.shopName ORDER BY src.$dateField, src.id) AS opening_balance") ->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shopName, $monthStart ORDER BY src.$dateField, src.id) AS month_first_rank") ->selectRaw("ROW_NUMBER() OVER (PARTITION BY src.shopName, $monthStart ORDER BY src.$dateField DESC, src.id DESC) AS month_last_rank"); $groups = $this->connection->query()->fromSub($ranked, 'ranked') ->when($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1, fn (Builder $query) => $query->where('month_start', $month . '-01')) ->selectRaw("shopName, month_start, COALESCE(SUM(income_value), 0) AS $incomeAlias, COALESCE(SUM(expense_value), 0) AS $expenseAlias") ->selectRaw("MAX(CASE WHEN month_first_rank = 1 THEN opening_balance END) AS $openingAlias") ->selectRaw("MAX(CASE WHEN month_last_rank = 1 THEN entry_balance END) AS $closingAlias") ->groupBy('shopName') ->groupBy('month_start'); $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, m.$openingAlias, m.$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); } }