query = $factory->forSource(self::SOURCE); } public function usesBusinessMirror(): bool { return $this->mirror?->ready(MarketingAccountBillMirrorRepository::ALIBABA) ?? false; } /** @return array{shops: string[], product_types: string[]} */ public function filterOptions(): array { if ($this->usesBusinessMirror()) { return $this->mirror->alibabaFilterOptions(); } $shops = $this->query->select( 'SELECT DISTINCT TOP (500) shopName FROM ' . self::TABLE . ' WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> \'\' ORDER BY shopName' ); $types = $this->query->select( 'SELECT DISTINCT TOP (500) tradeTypeName FROM ' . self::TABLE . ' WHERE tradeTypeName IS NOT NULL AND LTRIM(RTRIM(tradeTypeName)) <> \'\' ORDER BY tradeTypeName' ); return [ 'shops' => array_values(array_map(static fn (array $row): string => (string) ($row['shopName'] ?? ''), $shops)), 'product_types' => array_values(array_map(static fn (array $row): string => (string) ($row['tradeTypeName'] ?? ''), $types)), ]; } /** @return array{rows: array>, total: int} */ public function paginate( ?string $shopName = null, ?string $productType = null, ?string $fromDate = null, ?string $toDate = null, int $page = 1, int $pageSize = 25, ): array { $page = max(1, $page); $pageSize = min(max(1, $pageSize), 100); if ($this->usesBusinessMirror()) { $result = $this->mirror->paginateAlibaba( $shopName, $productType, $fromDate, $toDate, $page, $pageSize, ); return [ 'rows' => array_map(fn (array $row): array => $this->normalize($row), $result['rows']), 'total' => $result['total'], ]; } [$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate); $total = (int) $this->query->scalar('SELECT COUNT(*) FROM ' . self::TABLE . ' src' . $where, $bindings); if ($total === 0) { return ['rows' => [], 'total' => 0]; } $start = (($page - 1) * $pageSize) + 1; $end = $page * $pageSize; $sql = 'SELECT * FROM (' . 'SELECT src.id, src.shopName, src.tradeDay, src.tradeTypeName, ' . 'src.tradeAMTIn, src.tradeAMTOut, ' . 'ROW_NUMBER() OVER (ORDER BY src.tradeDay DESC, src.id DESC) AS rn ' . 'FROM ' . self::TABLE . ' src' . $where . ') AS t WHERE t.rn BETWEEN ? AND ? ORDER BY t.rn'; $rows = $this->query->select($sql, array_merge($bindings, [$start, $end])); return [ 'rows' => array_map(fn (array $row): array => $this->normalize($row), $rows), 'total' => $total, ]; } /** @return \Generator> */ public function exportRows( ?string $shopName = null, ?string $productType = null, ?string $fromDate = null, ?string $toDate = null, int $batchSize = 100, ): \Generator { if (! $this->usesBusinessMirror()) { throw new \RuntimeException('阿里巴巴账单共享业务镜像尚未完成同步。'); } $batchSize = max(1, min(100, $batchSize)); $page = 1; $exported = 0; do { $result = $this->mirror->paginateAlibaba( $shopName, $productType, $fromDate, $toDate, $page, $batchSize, ); $rows = is_array($result['rows'] ?? null) ? $result['rows'] : []; $total = max(0, (int) ($result['total'] ?? 0)); foreach ($rows as $row) { yield $this->normalize($row); } $count = count($rows); $exported += $count; $page++; } while ($count > 0 && $exported < $total); } /** @return array{income_total: float, expense_total: float, net: float, count: int} */ public function summary( ?string $shopName = null, ?string $productType = null, ?string $fromDate = null, ?string $toDate = null, ): array { if ($this->usesBusinessMirror()) { $row = $this->mirror->summarizeAlibaba($shopName, $productType, $fromDate, $toDate); } else { [$where, $bindings] = $this->where($shopName, $productType, $fromDate, $toDate); $row = $this->query->first( 'SELECT COALESCE(SUM(tradeAMTIn), 0) AS income_total, ' . 'COALESCE(SUM(tradeAMTOut), 0) AS expense_total, COUNT(*) AS cnt ' . 'FROM ' . self::TABLE . ' src' . $where, $bindings, ) ?? []; } $income = round((float) ($row['income_total'] ?? 0), 2); $expense = round((float) ($row['expense_total'] ?? 0), 2); return [ 'income_total' => $income, 'expense_total' => $expense, 'net' => round($income - $expense, 2), 'count' => (int) ($row['cnt'] ?? 0), ]; } /** @return array{0: string, 1: array} */ private function where(?string $shopName, ?string $productType, ?string $fromDate, ?string $toDate): array { $conditions = []; $bindings = []; if ($shopName !== null && $shopName !== '') { $conditions[] = 'src.shopName = ?'; $bindings[] = $shopName; } if ($productType !== null && $productType !== '') { $conditions[] = 'src.tradeTypeName = ?'; $bindings[] = $productType; } if ($fromDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fromDate) === 1) { $conditions[] = 'CAST(src.tradeDay AS date) >= CAST(? AS date)'; $bindings[] = $fromDate; } if ($toDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $toDate) === 1) { $conditions[] = 'CAST(src.tradeDay AS date) <= CAST(? AS date)'; $bindings[] = $toDate; } return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $bindings]; } private function normalize(array $row): array { return [ 'shop_name' => (string) ($row['shopName'] ?? ''), 'trade_day' => (string) ($row['tradeDay'] ?? ''), 'product_type' => (string) ($row['tradeTypeName'] ?? ''), 'income_amount' => round((float) ($row['tradeAMTIn'] ?? 0), 2), 'expense_amount' => round((float) ($row['tradeAMTOut'] ?? 0), 2), ]; } }