diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 62898918..2ee0f3c5 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -272,18 +272,22 @@ class Pages extends Controller : app(PinduoduoSpreadBillRepository::class); $scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list'; $cache = $this->upstreamCaches->forSource('jiansun'); + $usingMirror = $repository->usesBusinessMirror(); try { - $result = $cache->remember( - $scope, - ['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize], - fn () => $repository->paginateMonthlyBalances( - $filters['store_name'], - $filters['month'], - $page, - $pageSize, - ), + $resolver = fn () => $repository->paginateMonthlyBalances( + $filters['store_name'], + $filters['month'], + $page, + $pageSize, ); + $result = $usingMirror + ? $resolver() + : $cache->remember( + $scope, + ['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize], + $resolver, + ); } catch (UpstreamSnapshotPendingException $exception) { $content['pending'] = true; $result = ['rows' => [], 'total' => 0]; @@ -297,11 +301,14 @@ class Pages extends Controller } try { - $storeOptions = $cache->remember( - $isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options', - [], - fn () => $repository->storeOptions(), - ); + $resolver = fn () => $repository->storeOptions(); + $storeOptions = $usingMirror + ? $resolver() + : $cache->remember( + $isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options', + [], + $resolver, + ); } catch (Throwable $exception) { report($exception); $storeOptions = []; @@ -352,11 +359,14 @@ class Pages extends Controller ], static fn (mixed $value): bool => $value !== ''); try { - $result = $this->upstreamCaches->forSource('jiansun')->remember( - $scope, - ['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true], - fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000), - ); + $resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000); + $result = $repository->usesBusinessMirror() + ? $resolver() + : $this->upstreamCaches->forSource('jiansun')->remember( + $scope, + ['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true], + $resolver, + ); } catch (ServiceUnavailableHttpException) { return redirect()->route('business.pages.show', $redirectParameters) ->with('error', '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。'); @@ -880,28 +890,38 @@ class Pages extends Controller ]; $repository = app(AlibabaBillRepository::class); $cache = $this->upstreamCaches->forSource('jiansun'); + $usingMirror = $repository->usesBusinessMirror(); try { - $result = $cache->remember( - 'marketing-account-bills.alibaba.list', - ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], - fn () => $repository->paginate( - $filters['shop_name'], $filters['product_type'], - $filters['from'], $filters['to'], $page, $pageSize, - ), + $listResolver = fn () => $repository->paginate( + $filters['shop_name'], $filters['product_type'], + $filters['from'], $filters['to'], $page, $pageSize, ); - $summary = $cache->remember( - 'marketing-account-bills.alibaba.summary', - ['filters' => $filters], - fn () => $repository->summary( - $filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'], - ), - ); - $options = $cache->remember( - 'marketing-account-bills.alibaba.options', - [], - fn () => $repository->filterOptions(), + $summaryResolver = fn () => $repository->summary( + $filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'], ); + $optionsResolver = fn () => $repository->filterOptions(); + $result = $usingMirror + ? $listResolver() + : $cache->remember( + 'marketing-account-bills.alibaba.list', + ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], + $listResolver, + ); + $summary = $usingMirror + ? $summaryResolver() + : $cache->remember( + 'marketing-account-bills.alibaba.summary', + ['filters' => $filters], + $summaryResolver, + ); + $options = $usingMirror + ? $optionsResolver() + : $cache->remember( + 'marketing-account-bills.alibaba.options', + [], + $optionsResolver, + ); } catch (ServiceUnavailableHttpException $exception) { return [ 'view' => 'banking.marketing_alibaba_bills.partials.embedded-table', @@ -966,33 +986,43 @@ class Pages extends Controller 'flow_type' => $flowType !== '' ? $flowType : null, ]; - $result = $cache->remember( - 'taobao-alipay-bills.list', - ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], - fn () => $repository->paginate( - $filters['store_name'], - $filters['from'], - $filters['to'], - $filters['flow_type'], - $page, - $pageSize - ) + $listResolver = fn () => $repository->paginate( + $filters['store_name'], + $filters['from'], + $filters['to'], + $filters['flow_type'], + $page, + $pageSize ); - $summary = $cache->remember( - 'taobao-alipay-bills.summary', - ['filters' => $filters], - fn () => $repository->summary( - $filters['store_name'], - $filters['from'], - $filters['to'], - $filters['flow_type'] - ) - ); - $storeOptions = $cache->remember( - 'taobao-alipay-bills.options', - [], - fn () => $repository->storeOptions() + $summaryResolver = fn () => $repository->summary( + $filters['store_name'], + $filters['from'], + $filters['to'], + $filters['flow_type'] ); + $optionsResolver = fn () => $repository->storeOptions(); + $usingMirror = $repository->usesBusinessMirror(); + $result = $usingMirror + ? $listResolver() + : $cache->remember( + 'taobao-alipay-bills.list', + ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], + $listResolver, + ); + $summary = $usingMirror + ? $summaryResolver() + : $cache->remember( + 'taobao-alipay-bills.summary', + ['filters' => $filters], + $summaryResolver, + ); + $storeOptions = $usingMirror + ? $optionsResolver() + : $cache->remember( + 'taobao-alipay-bills.options', + [], + $optionsResolver, + ); $bills = new LengthAwarePaginator( Collection::make($result['rows']), @@ -1041,34 +1071,43 @@ class Pages extends Controller ]; // 页面只读取共享查询快照;冷查询由统一限流和熔断链路访问上游。 - $result = $cache->remember( - 'pinduoduo-bills.list', - ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], - fn () => $repository->paginate( - $filters['store_name'], - $filters['from'], - $filters['to'], - $filters['flow_type'], - $page, - $pageSize - ) + $listResolver = fn () => $repository->paginate( + $filters['store_name'], + $filters['from'], + $filters['to'], + $filters['flow_type'], + $page, + $pageSize ); - $summary = $cache->remember( - 'pinduoduo-bills.summary', - ['filters' => $filters], - fn () => $repository->summary( - $filters['store_name'], - $filters['from'], - $filters['to'], - $filters['flow_type'] - ) - ); - - $storeOptions = $cache->remember( - 'pinduoduo-bills.options', - [], - fn () => $repository->storeOptions() + $summaryResolver = fn () => $repository->summary( + $filters['store_name'], + $filters['from'], + $filters['to'], + $filters['flow_type'] ); + $optionsResolver = fn () => $repository->storeOptions(); + $usingMirror = $repository->usesBusinessMirror(); + $result = $usingMirror + ? $listResolver() + : $cache->remember( + 'pinduoduo-bills.list', + ['filters' => $filters, 'page' => $page, 'limit' => $pageSize], + $listResolver, + ); + $summary = $usingMirror + ? $summaryResolver() + : $cache->remember( + 'pinduoduo-bills.summary', + ['filters' => $filters], + $summaryResolver, + ); + $storeOptions = $usingMirror + ? $optionsResolver() + : $cache->remember( + 'pinduoduo-bills.options', + [], + $optionsResolver, + ); // 包装成 Laravel 分页器,便于 Blade 用 $bills->links() $bills = new LengthAwarePaginator( @@ -1114,9 +1153,10 @@ class Pages extends Controller $cache = $this->upstreamCaches->forSource('jiansun'); $scopePrefix = 'jd-wallet-bills.' . $ledger; $errors = []; - $remember = function (string $scope, array $parameters, callable $resolver, mixed $fallback) use ($cache, &$errors): mixed { + $usingMirror = $repository->usesBusinessMirror($ledger); + $remember = function (string $scope, array $parameters, callable $resolver, mixed $fallback) use ($cache, $usingMirror, &$errors): mixed { try { - return $cache->remember($scope, $parameters, $resolver); + return $usingMirror ? $resolver() : $cache->remember($scope, $parameters, $resolver); } catch (ServiceUnavailableHttpException $exception) { $errors[] = $exception->getMessage(); } catch (Throwable $exception) { @@ -1145,12 +1185,19 @@ class Pages extends Controller : $repository->summaryRecharge($filters['store_name'], $filters['from'], $filters['to']), ['amount_total' => 0, 'balance_total' => 0, 'count' => 0], ); - $shopOptions = $remember( - 'jd-wallet-bills.options', - [], - fn () => $repository->shopOptions(), - [], - ); + try { + $optionsResolver = fn () => $repository->shopOptions(); + $shopOptions = $repository->usesBusinessMirrorForOptions() + ? $optionsResolver() + : $cache->remember('jd-wallet-bills.options', [], $optionsResolver); + } catch (ServiceUnavailableHttpException $exception) { + $errors[] = $exception->getMessage(); + $shopOptions = []; + } catch (Throwable $exception) { + report($exception); + $errors[] = '京东账单数据暂时无法读取,请稍后重试。'; + $shopOptions = []; + } return [ 'view' => 'banking.marketing_jd_wallet_bills.partials.embedded-table', diff --git a/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php b/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php index f1dea8d9..cb5b8296 100644 --- a/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php +++ b/modules/EcommerceFinance/Repositories/AlibabaBillRepository.php @@ -17,14 +17,25 @@ class AlibabaBillRepository private UpstreamReadOnlyQuery $query; - public function __construct(UpstreamReadOnlyQueryFactory $factory) - { + 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' @@ -51,6 +62,23 @@ class AlibabaBillRepository ): 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); @@ -82,13 +110,17 @@ class AlibabaBillRepository ?string $fromDate = null, ?string $toDate = null, ): array { - [$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, - ) ?? []; + 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); diff --git a/modules/EcommerceFinance/Repositories/JdWalletBillRepository.php b/modules/EcommerceFinance/Repositories/JdWalletBillRepository.php index 77d9f126..4c22d7d4 100644 --- a/modules/EcommerceFinance/Repositories/JdWalletBillRepository.php +++ b/modules/EcommerceFinance/Repositories/JdWalletBillRepository.php @@ -19,14 +19,34 @@ class JdWalletBillRepository private UpstreamReadOnlyQuery $query; - public function __construct(UpstreamReadOnlyQueryFactory $factory) - { + public function __construct( + UpstreamReadOnlyQueryFactory $factory, + private readonly ?MarketingAccountBillMirrorRepository $mirror = null, + ) { $this->query = $factory->forSource(self::SOURCE); } + public function usesBusinessMirror(string $ledger): bool + { + $dataset = $ledger === 'consumption' + ? MarketingAccountBillMirrorRepository::JD_CONSUMPTION + : MarketingAccountBillMirrorRepository::JD_RECHARGE; + + return $this->mirror?->ready($dataset) ?? false; + } + + public function usesBusinessMirrorForOptions(): bool + { + return $this->usesBusinessMirror('recharge') && $this->usesBusinessMirror('consumption'); + } + /** @return string[] */ public function shopOptions(): array { + if ($this->usesBusinessMirrorForOptions()) { + return $this->mirror->jdShopOptions(); + } + $rows = $this->query->select( "SELECT TOP (500) shopName FROM (SELECT shopName FROM " . self::RECHARGE_TABLE . " UNION SELECT shopName FROM " . self::CONSUMPTION_TABLE @@ -47,6 +67,21 @@ class JdWalletBillRepository int $page = 1, int $pageSize = 25, ): array { + if ($this->usesBusinessMirror('recharge')) { + $result = $this->mirror->paginateJdRecharge( + $shopName, + $fromDate, + $toDate, + $page, + $pageSize, + ); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalizeRecharge($row), $result['rows']), + 'total' => $result['total'], + ]; + } + return $this->paginate(self::RECHARGE_TABLE, $this->rechargeWhere($shopName, $fromDate, $toDate), $page, $pageSize, [ 'id', 'shopName', 'amount', 'balanceAmount', 'orderTypeName', 'pk', 'createDate', 'updateTime', 'statusName', 'remark', @@ -61,6 +96,21 @@ class JdWalletBillRepository int $page = 1, int $pageSize = 25, ): array { + if ($this->usesBusinessMirror('consumption')) { + $result = $this->mirror->paginateJdConsumption( + $shopName, + $fromDate, + $toDate, + $page, + $pageSize, + ); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalizeConsumption($row), $result['rows']), + 'total' => $result['total'], + ]; + } + return $this->paginate(self::CONSUMPTION_TABLE, $this->consumptionWhere($shopName, $fromDate, $toDate), $page, $pageSize, [ 'id', 'shopName', 'amount', 'balanceAmount', 'orderTypeName', 'creatTime', 'settleTime', 'pk', 'invoiceApplyTypeName', @@ -70,12 +120,32 @@ class JdWalletBillRepository /** @return array{amount_total: float, balance_total: float, count: int} */ public function summaryRecharge(?string $shopName = null, ?string $fromDate = null, ?string $toDate = null): array { + if ($this->usesBusinessMirror('recharge')) { + return $this->normalizeSummary($this->mirror->summarizeJd( + MarketingAccountBillMirrorRepository::JD_RECHARGE, + 'createDate', + $shopName, + $fromDate, + $toDate, + )); + } + return $this->summary(self::RECHARGE_TABLE, $this->rechargeWhere($shopName, $fromDate, $toDate)); } /** @return array{amount_total: float, balance_total: float, count: int} */ public function summaryConsumption(?string $shopName = null, ?string $fromDate = null, ?string $toDate = null): array { + if ($this->usesBusinessMirror('consumption')) { + return $this->normalizeSummary($this->mirror->summarizeJd( + MarketingAccountBillMirrorRepository::JD_CONSUMPTION, + 'creatTime', + $shopName, + $fromDate, + $toDate, + )); + } + return $this->summary(self::CONSUMPTION_TABLE, $this->consumptionWhere($shopName, $fromDate, $toDate)); } @@ -111,6 +181,12 @@ class JdWalletBillRepository $where['bindings'], ) ?? []; + return $this->normalizeSummary($row); + } + + /** @return array{amount_total: float, balance_total: float, count: int} */ + private function normalizeSummary(array $row): array + { return [ 'amount_total' => round((float) ($row['amount_total'] ?? 0), 2), 'balance_total' => round((float) ($row['balance_total'] ?? 0), 2), diff --git a/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php new file mode 100644 index 00000000..d76d5dfb --- /dev/null +++ b/modules/EcommerceFinance/Repositories/MarketingAccountBillMirrorRepository.php @@ -0,0 +1,559 @@ + */ + 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')"; + $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); + } +} diff --git a/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php b/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php index 8858a2f1..022179a8 100644 --- a/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php +++ b/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php @@ -41,11 +41,18 @@ class PinduoduoSpreadBillRepository private UpstreamReadOnlyQuery $query; - public function __construct(UpstreamReadOnlyQueryFactory $factory) - { + 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::PINDUODUO) ?? false; + } + /** * 店铺下拉选项(全部去重) * @@ -53,6 +60,10 @@ class PinduoduoSpreadBillRepository */ public function storeOptions(): array { + if ($this->usesBusinessMirror()) { + return $this->mirror->pinduoduoStoreOptions(); + } + $rows = $this->query->select( 'SELECT DISTINCT shopName FROM ' . self::TABLE . ' WHERE shopName IS NOT NULL ORDER BY shopName' ); @@ -79,6 +90,22 @@ class PinduoduoSpreadBillRepository $page = max(1, $page); $pageSize = max(1, min(200, $pageSize)); + if ($this->usesBusinessMirror()) { + $result = $this->mirror->paginatePinduoduo( + $storeName, + $fromDate, + $toDate, + $flowType, + $page, + $pageSize, + ); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalize($row), $result['rows']), + 'total' => $result['total'], + ]; + } + [$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType); // 总数 @@ -125,16 +152,20 @@ class PinduoduoSpreadBillRepository ?string $toDate = null, ?string $flowType = null ): array { - [$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType); + if ($this->usesBusinessMirror()) { + $row = $this->mirror->summarizePinduoduo($storeName, $fromDate, $toDate, $flowType); + } else { + [$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType); - $row = $this->query->first( - 'SELECT ' - . 'COALESCE(SUM(CASE WHEN flowType = 1 THEN amount ELSE 0 END), 0) AS income_total, ' - . 'COALESCE(SUM(CASE WHEN flowType = 2 THEN amount ELSE 0 END), 0) AS expense_total, ' - . 'COUNT(*) AS cnt ' - . 'FROM ' . self::TABLE . $whereSql, - $params - ); + $row = $this->query->first( + 'SELECT ' + . 'COALESCE(SUM(CASE WHEN flowType = 1 THEN amount ELSE 0 END), 0) AS income_total, ' + . 'COALESCE(SUM(CASE WHEN flowType = 2 THEN amount ELSE 0 END), 0) AS expense_total, ' + . 'COUNT(*) AS cnt ' + . 'FROM ' . self::TABLE . $whereSql, + $params + ); + } $income = (float) ($row['income_total'] ?? 0); $expense = (float) ($row['expense_total'] ?? 0); @@ -156,6 +187,16 @@ class PinduoduoSpreadBillRepository ): array { $page = max(1, $page); $pageSize = max(1, min(5000, $pageSize)); + + if ($this->usesBusinessMirror()) { + $result = $this->mirror->paginatePinduoduoMonthlyBalances($storeName, $month, $page, $pageSize); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalizeMonthlyBalance($row), $result['rows']), + 'total' => $result['total'], + ]; + } + $monthExpression = 'DATEADD(MONTH, DATEDIFF(MONTH, 0, createTime), 0)'; [$whereSql, $params] = $this->buildMonthlyWhere($storeName, $month); diff --git a/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php b/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php index ff41da31..ccbf69dc 100644 --- a/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php +++ b/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php @@ -58,11 +58,18 @@ class TmallTaobaoSpreadBillRepository private UpstreamReadOnlyQuery $query; - public function __construct(UpstreamReadOnlyQueryFactory $factory) - { + 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::TAOBAO) ?? false; + } + /** * 店铺下拉选项(全部去重) * @@ -70,6 +77,10 @@ class TmallTaobaoSpreadBillRepository */ public function storeOptions(): array { + if ($this->usesBusinessMirror()) { + return $this->mirror->taobaoStoreOptions(); + } + $rows = $this->query->select( 'SELECT DISTINCT shopName FROM ' . self::TABLE . ' WHERE shopName IS NOT NULL ORDER BY shopName' ); @@ -96,6 +107,22 @@ class TmallTaobaoSpreadBillRepository $page = max(1, $page); $pageSize = max(1, min(200, $pageSize)); + if ($this->usesBusinessMirror()) { + $result = $this->mirror->paginateTaobao( + $storeName, + $fromDate, + $toDate, + $flowType, + $page, + $pageSize, + ); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalize($row), $result['rows']), + 'total' => $result['total'], + ]; + } + [$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType); // 总数 @@ -143,17 +170,21 @@ class TmallTaobaoSpreadBillRepository ?string $toDate = null, ?string $flowType = null ): array { - [$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType); + if ($this->usesBusinessMirror()) { + $row = $this->mirror->summarizeTaobao($storeName, $fromDate, $toDate, $flowType); + } else { + [$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType); - // amount 单位为「分」,汇总时除以 100 转为「元」 - $row = $this->query->first( - 'SELECT ' - . 'COALESCE(SUM(CASE WHEN finType = \'DEBIT\' THEN amount ELSE 0 END), 0) AS income_total_raw, ' - . 'COALESCE(SUM(CASE WHEN finType = \'CREDIT\' THEN amount ELSE 0 END), 0) AS expense_total_raw, ' - . 'COUNT(*) AS cnt ' - . 'FROM ' . self::TABLE . $whereSql, - $params - ); + // amount 单位为「分」,汇总时除以 100 转为「元」 + $row = $this->query->first( + 'SELECT ' + . 'COALESCE(SUM(CASE WHEN finType = \'DEBIT\' THEN amount ELSE 0 END), 0) AS income_total_raw, ' + . 'COALESCE(SUM(CASE WHEN finType = \'CREDIT\' THEN amount ELSE 0 END), 0) AS expense_total_raw, ' + . 'COUNT(*) AS cnt ' + . 'FROM ' . self::TABLE . $whereSql, + $params + ); + } // 分 -> 元 $income = ((float) ($row['income_total_raw'] ?? 0)) / 100; @@ -176,6 +207,16 @@ class TmallTaobaoSpreadBillRepository ): array { $page = max(1, $page); $pageSize = max(1, min(5000, $pageSize)); + + if ($this->usesBusinessMirror()) { + $result = $this->mirror->paginateTaobaoMonthlyBalances($storeName, $month, $page, $pageSize); + + return [ + 'rows' => array_map(fn (array $row): array => $this->normalizeMonthlyBalance($row), $result['rows']), + 'total' => $result['total'], + ]; + } + $monthExpression = 'DATEADD(MONTH, DATEDIFF(MONTH, 0, transTime), 0)'; [$where, $params] = $this->buildMonthlyWhere($storeName, $month); diff --git a/tests/Feature/Business/MarketingAccountBillsTabsTest.php b/tests/Feature/Business/MarketingAccountBillsTabsTest.php index d12be030..537a171f 100644 --- a/tests/Feature/Business/MarketingAccountBillsTabsTest.php +++ b/tests/Feature/Business/MarketingAccountBillsTabsTest.php @@ -6,6 +6,7 @@ use App\Models\Auth\Permission; use Mockery; use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; +use Modules\EcommerceFinance\Repositories\MarketingAccountBillMirrorRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCache; @@ -15,10 +16,20 @@ use Tests\Feature\FeatureTestCase; class MarketingAccountBillsTabsTest extends FeatureTestCase { + protected function setUp(): void + { + parent::setUp(); + + $mirror = Mockery::mock(MarketingAccountBillMirrorRepository::class); + $mirror->shouldReceive('ready')->byDefault()->andReturnFalse(); + $this->app->instance(MarketingAccountBillMirrorRepository::class, $mirror); + } + public function testMarketingAccountBalanceRedirectsToTaobaoAndShowsRestoredControls(): void { $this->allowMarketingAccountBalancePage(); $repository = Mockery::mock(PinduoduoSpreadBillRepository::class); + $repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse(); $this->app->instance(PinduoduoSpreadBillRepository::class, $repository); $cache = Mockery::mock(UpstreamQueryCache::class); $cache->shouldReceive('remember')->twice()->andReturnUsing( @@ -141,6 +152,10 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $this->app->instance(JdWalletBillRepository::class, Mockery::mock(JdWalletBillRepository::class)); + $this->app->make(JdWalletBillRepository::class) + ->shouldReceive('usesBusinessMirror')->once()->with('recharge')->andReturnFalse(); + $this->app->make(JdWalletBillRepository::class) + ->shouldReceive('usesBusinessMirrorForOptions')->once()->andReturnFalse(); $this->loginAs() ->get(route('business.pages.show', [ @@ -177,7 +192,9 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase ->andReturn($cache); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); - $this->app->instance(TmallTaobaoSpreadBillRepository::class, Mockery::mock(TmallTaobaoSpreadBillRepository::class)); + $repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class); + $repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse(); + $this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository); $this->loginAs() ->get(route('business.pages.export', [ @@ -214,7 +231,9 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase $factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); - $this->app->instance(TmallTaobaoSpreadBillRepository::class, Mockery::mock(TmallTaobaoSpreadBillRepository::class)); + $repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class); + $repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse(); + $this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository); $this->loginAs() ->get(route('business.pages.show', [ @@ -239,7 +258,9 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase $factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); - $this->app->instance(TmallTaobaoSpreadBillRepository::class, Mockery::mock(TmallTaobaoSpreadBillRepository::class)); + $repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class); + $repository->shouldReceive('usesBusinessMirror')->once()->andReturnFalse(); + $this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository); $this->loginAs() ->get(route('business.pages.export', [ @@ -255,6 +276,88 @@ class MarketingAccountBillsTabsTest extends FeatureTestCase ->assertSessionHas('error', '余额数据导出暂时无法完成,请稍后重试。'); } + public function testMarketingBillSecondPageReadsReadyMirrorWithoutRegisteringAColdSnapshot(): void + { + $this->allowMarketingAccountBillsPage(); + + $repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class); + $repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue(); + $repository->shouldReceive('paginate') + ->once() + ->with(null, null, null, null, 2, 25) + ->andReturn(['rows' => [['store_name' => '镜像店铺']], 'total' => 26]); + $repository->shouldReceive('summary')->once()->andReturn([ + 'income_total' => 0, + 'expense_total' => 0, + 'balance' => 0, + 'count' => 26, + ]); + $repository->shouldReceive('storeOptions')->once()->andReturn(['镜像店铺']); + $this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository); + + $cache = Mockery::mock(UpstreamQueryCache::class); + $cache->shouldNotReceive('remember'); + $factory = Mockery::mock(UpstreamQueryCacheFactory::class); + $factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache); + $this->app->instance(UpstreamQueryCacheFactory::class, $factory); + + $this->loginAs(); + $url = route('business.pages.show', [ + 'section' => 'billing-transactions', + 'page' => 'marketing-account-bills', + 'tab' => 'taobao-alipay', + ]) . '?page=2'; + + $this->get($url) + ->assertOk() + ->assertSeeText('镜像店铺') + ->assertDontSeeText('首份共享快照正在准备'); + } + + public function testMarketingBalanceSecondPageReadsReadyMirrorWithoutRegisteringAColdSnapshot(): void + { + $this->allowMarketingAccountBalancePage(); + + $repository = Mockery::mock(TmallTaobaoSpreadBillRepository::class); + $repository->shouldReceive('usesBusinessMirror')->once()->andReturnTrue(); + $repository->shouldReceive('paginateMonthlyBalances') + ->once() + ->with(null, null, 2, 25) + ->andReturn(['rows' => [[ + 'date' => '2026-08', + 'store_name' => '镜像余额店铺', + 'opening_balance' => 100, + 'income_total' => 20, + 'expense_total' => 5, + 'closing_balance' => 115, + 'system_closing_balance' => 115, + 'balance_difference' => 0, + 'difference_reason' => '', + 'verification_status' => '一致', + 'notes' => '', + ]], 'total' => 26]); + $repository->shouldReceive('storeOptions')->once()->andReturn(['镜像余额店铺']); + $this->app->instance(TmallTaobaoSpreadBillRepository::class, $repository); + + $cache = Mockery::mock(UpstreamQueryCache::class); + $cache->shouldNotReceive('remember'); + $factory = Mockery::mock(UpstreamQueryCacheFactory::class); + $factory->shouldReceive('forSource')->once()->with('jiansun')->andReturn($cache); + $this->app->instance(UpstreamQueryCacheFactory::class, $factory); + + $this->loginAs(); + $url = route('business.pages.show', [ + 'section' => 'billing-transactions', + 'page' => 'marketing-account-balance', + 'tab' => 'taobao-alipay', + ]) . '?page=2'; + + $this->get($url) + ->assertOk() + ->assertSeeText('镜像余额店铺') + ->assertDontSeeText('后台正在准备首份共享数据'); + } + private function allowMarketingAccountBillsPage(): void { $this->user->update(['locale' => 'zh-CN']); diff --git a/tests/Unit/EcommerceFinance/MarketingAccountBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/MarketingAccountBillMirrorRepositoryTest.php new file mode 100644 index 00000000..856b269f --- /dev/null +++ b/tests/Unit/EcommerceFinance/MarketingAccountBillMirrorRepositoryTest.php @@ -0,0 +1,209 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.datasets' => [ + MarketingAccountBillMirrorRepository::TAOBAO => $this->definition('tmall_spread_bills', 'js_tmall_spread_bills'), + MarketingAccountBillMirrorRepository::PINDUODUO => $this->definition('pdd_spread_bills', 'js_pdd_spread_bills'), + MarketingAccountBillMirrorRepository::ALIBABA => $this->definition('alibaba_bill', 'js_alibaba_bill'), + MarketingAccountBillMirrorRepository::JD_RECHARGE => $this->definition('jd_spreadBills', 'js_jd_spread_bills'), + MarketingAccountBillMirrorRepository::JD_CONSUMPTION => $this->definition('jd_costBills', 'js_jd_cost_bills'), + ], + ]); + DB::purge('business_mirror'); + $this->createTables(); + + $generic = new GenericBusinessMirrorRepository(app('db')); + $this->mirror = new MarketingAccountBillMirrorRepository( + new BusinessMirrorDatasetRegistry(), + $generic, + ); + } + + public function test_ready_mirrors_drive_lists_summaries_options_monthly_balances_and_second_pages(): void + { + $db = DB::connection('business_mirror'); + $db->table('js_tmall_spread_bills')->insert([ + $this->taobao(1, '2026-05-31 23:00:00', 10000, 10000, 'DEBIT'), + $this->taobao(2, '2026-06-01 09:00:00', 5000, 15000, 'DEBIT'), + $this->taobao(3, '2026-06-02 09:00:00', 2000, 13000, 'CREDIT'), + ]); + $db->table('js_pdd_spread_bills')->insert([ + $this->pinduoduo(1, '2026-05-31 23:00:00', 100, 100, 1), + $this->pinduoduo(2, '2026-06-01 09:00:00', 50, 150, 1), + $this->pinduoduo(3, '2026-06-02 09:00:00', 20, 130, 2), + ]); + $db->table('js_alibaba_bill')->insert([ + ['id' => 1, 'shopName' => '阿里店', 'tradeDay' => '2026-06-01 09:00:00', 'tradeTypeName' => '全站', 'tradeAMTIn' => 10, 'tradeAMTOut' => 1], + ['id' => 2, 'shopName' => '阿里店', 'tradeDay' => '2026-06-02 09:00:00', 'tradeTypeName' => '全站', 'tradeAMTIn' => 20, 'tradeAMTOut' => 2], + ]); + $db->table('js_jd_spread_bills')->insert([ + ['id' => 1, 'shopName' => '京东店', 'amount' => 100, 'balanceAmount' => 100, 'orderTypeName' => '充值', 'pk' => 'R1', 'createDate' => '2026-06-01 10:00:00', 'updateTime' => '2026-06-01 10:01:00', 'statusName' => '成功', 'remark' => ''], + ['id' => 2, 'shopName' => '京东店', 'amount' => 200, 'balanceAmount' => 300, 'orderTypeName' => '充值', 'pk' => 'R2', 'createDate' => '2026-06-02 10:00:00', 'updateTime' => '2026-06-02 10:01:00', 'statusName' => '成功', 'remark' => ''], + ]); + $db->table('js_jd_cost_bills')->insert([ + ['id' => 1, 'shopName' => '京东店', 'amount' => -30, 'balanceAmount' => 270, 'orderTypeName' => '消耗', 'creatTime' => '2026-06-03 10:00:00', 'settleTime' => '2026-06-04 00:00:00', 'pk' => 'C1', 'invoiceApplyTypeName' => '推广费'], + ]); + $this->markReady(); + + $factory = $this->throwingFactory(); + $taobao = new TmallTaobaoSpreadBillRepository($factory, $this->mirror); + $pinduoduo = new PinduoduoSpreadBillRepository($factory, $this->mirror); + $alibaba = new AlibabaBillRepository($factory, $this->mirror); + $jd = new JdWalletBillRepository($factory, $this->mirror); + + $taobaoPage2 = $taobao->paginate('镜像店铺', '2026-06-01', '2026-06-30', null, 2, 1); + $taobaoSummary = $taobao->summary('镜像店铺', '2026-06-01', '2026-06-30'); + $taobaoMonthly = $taobao->paginateMonthlyBalances('镜像店铺', '2026-06', 1, 25); + $pddPage2 = $pinduoduo->paginate('镜像店铺', '2026-06-01', '2026-06-30', null, 2, 1); + $pddSummary = $pinduoduo->summary('镜像店铺', '2026-06-01', '2026-06-30'); + $pddMonthly = $pinduoduo->paginateMonthlyBalances('镜像店铺', '2026-06', 1, 25); + $alibabaPage2 = $alibaba->paginate('阿里店', '全站', '2026-06-01', '2026-06-30', 2, 1); + $jdRechargePage2 = $jd->paginateRecharge('京东店', '2026-06-01', '2026-06-30', 2, 1); + + $this->assertSame(2, $taobaoPage2['total']); + $this->assertSame(50.0, $taobaoPage2['rows'][0]['amount']); + $this->assertSame(['镜像店铺'], $taobao->storeOptions()); + $this->assertSame(30.0, $taobaoSummary['net']); + $this->assertSame(130.0, $taobaoMonthly['rows'][0]['closing_balance']); + $this->assertSame(0.0, $taobaoMonthly['rows'][0]['balance_difference']); + + $this->assertSame(2, $pddPage2['total']); + $this->assertSame(50.0, $pddPage2['rows'][0]['amount']); + $this->assertSame(30.0, $pddSummary['net']); + $this->assertSame(130.0, $pddMonthly['rows'][0]['closing_balance']); + $this->assertSame(0.0, $pddMonthly['rows'][0]['balance_difference']); + + $this->assertSame(2, $alibabaPage2['total']); + $this->assertSame(10.0, $alibabaPage2['rows'][0]['income_amount']); + $this->assertSame(['shops' => ['阿里店'], 'product_types' => ['全站']], $alibaba->filterOptions()); + $this->assertSame(27.0, $alibaba->summary('阿里店', '全站')['net']); + + $this->assertSame(2, $jdRechargePage2['total']); + $this->assertSame('R1', $jdRechargePage2['rows'][0]['order_number']); + $this->assertSame(['京东店'], $jd->shopOptions()); + $this->assertSame(300.0, $jd->summaryRecharge('京东店')['amount_total']); + $this->assertSame(30.0, $jd->summaryConsumption('京东店')['amount_total']); + } + + public function test_unready_dataset_keeps_the_existing_upstream_repository_path(): void + { + $query = new class('jiansun') extends UpstreamReadOnlyQuery { + public function scalar(string $sql, array $bindings = []): mixed { return 1; } + public function select(string $sql, array $bindings = []): array + { + return [['shopName' => '上游回退', 'amount' => 8, 'balance' => 9, 'summary' => '', 'flowType' => 1, 'toAdvertiserFundType' => 1, 'createTime' => '2026-06-01']]; + } + }; + $factory = new class($query) extends UpstreamReadOnlyQueryFactory { + public function __construct(private readonly UpstreamReadOnlyQuery $query) {} + public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; } + }; + + $result = (new PinduoduoSpreadBillRepository($factory, $this->mirror))->paginate(); + + $this->assertSame(1, $result['total']); + $this->assertSame('上游回退', $result['rows'][0]['store_name']); + } + + private function throwingFactory(): UpstreamReadOnlyQueryFactory + { + $query = new class('jiansun') extends UpstreamReadOnlyQuery { + public function scalar(string $sql, array $bindings = []): mixed { throw new RuntimeException('upstream_should_not_be_called'); } + public function select(string $sql, array $bindings = []): array { throw new RuntimeException('upstream_should_not_be_called'); } + public function first(string $sql, array $bindings = []): ?array { throw new RuntimeException('upstream_should_not_be_called'); } + }; + + return new class($query) extends UpstreamReadOnlyQueryFactory { + public function __construct(private readonly UpstreamReadOnlyQuery $query) {} + public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; } + }; + } + + private function createTables(): void + { + $schema = Schema::connection('business_mirror'); + $schema->create('business_mirror_table_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->dateTime('initial_completed_at')->nullable(); + }); + $schema->create('js_tmall_spread_bills', function (Blueprint $table): void { + $table->integer('id'); $table->string('shopName')->nullable(); $table->dateTime('uniTransTime')->nullable(); $table->dateTime('transTime')->nullable(); + $table->decimal('amount', 18, 2)->nullable(); $table->decimal('balance', 18, 2)->nullable(); $table->string('comment')->nullable(); + $table->string('finType')->nullable(); $table->string('bizType')->nullable(); $table->string('tradeType')->nullable(); + }); + $schema->create('js_pdd_spread_bills', function (Blueprint $table): void { + $table->integer('id'); $table->string('shopName')->nullable(); $table->decimal('amount', 18, 2)->nullable(); $table->decimal('balance', 18, 2)->nullable(); + $table->string('summary')->nullable(); $table->integer('flowType')->nullable(); $table->integer('toAdvertiserFundType')->nullable(); $table->dateTime('createTime')->nullable(); + }); + $schema->create('js_alibaba_bill', function (Blueprint $table): void { + $table->integer('id'); $table->string('shopName')->nullable(); $table->dateTime('tradeDay')->nullable(); $table->string('tradeTypeName')->nullable(); + $table->decimal('tradeAMTIn', 18, 2)->nullable(); $table->decimal('tradeAMTOut', 18, 2)->nullable(); + }); + $schema->create('js_jd_spread_bills', function (Blueprint $table): void { + $table->integer('id'); $table->string('shopName')->nullable(); $table->decimal('amount', 18, 2)->nullable(); $table->decimal('balanceAmount', 18, 2)->nullable(); + $table->string('orderTypeName')->nullable(); $table->string('pk')->nullable(); $table->dateTime('createDate')->nullable(); $table->dateTime('updateTime')->nullable(); + $table->string('statusName')->nullable(); $table->string('remark')->nullable(); + }); + $schema->create('js_jd_cost_bills', function (Blueprint $table): void { + $table->integer('id'); $table->string('shopName')->nullable(); $table->decimal('amount', 18, 2)->nullable(); $table->decimal('balanceAmount', 18, 2)->nullable(); + $table->string('orderTypeName')->nullable(); $table->dateTime('creatTime')->nullable(); $table->dateTime('settleTime')->nullable(); + $table->string('pk')->nullable(); $table->string('invoiceApplyTypeName')->nullable(); + }); + } + + private function markReady(): void + { + foreach (array_keys((array) config('upstream_mirrors.datasets')) as $key) { + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => $key, + 'initial_completed_at' => '2026-08-14 12:00:00', + ]); + } + } + + private function definition(string $object, string $target): array + { + return ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => $object, 'target' => $target, 'keys' => ['id']]; + } + + private function taobao(int $id, string $time, float $amount, float $balance, string $finType): array + { + return ['id' => $id, 'shopName' => '镜像店铺', 'uniTransTime' => $time, 'transTime' => $time, 'amount' => $amount, 'balance' => $balance, 'comment' => '', 'finType' => $finType, 'bizType' => '', 'tradeType' => '']; + } + + private function pinduoduo(int $id, string $time, float $amount, float $balance, int $flowType): array + { + return ['id' => $id, 'shopName' => '镜像店铺', 'amount' => $amount, 'balance' => $balance, 'summary' => '', 'flowType' => $flowType, 'toAdvertiserFundType' => 1, 'createTime' => $time]; + } +}