db()->getSchemaBuilder()->hasTable('douyin_authoritative_periods') && $this->db()->table('douyin_authoritative_periods')->exists(); } public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array { $query = $this->query($filters); $summary = (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first(); $total = (int) $summary->count; $page = max(1, $page); $perPage = min(100, max(1, $perPage)); $rows = $this->ordered($query, $sort, $direction)->forPage($page, $perPage)->get()->all(); $income = round((float) $summary->income, 2); $expense = round((float) $summary->expense, 2); $state = $this->mirror->state($this->datasets->get(StoreReceivingAlipayBillMirrorRepository::DATASET)) ?? []; return [ 'data' => $this->mapRows($rows), 'total' => $total, 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage, 'summary' => ['count' => $total, 'income' => $income, 'expense' => $expense, 'balance' => round($income - $expense, 2)], 'sync' => ['status' => $state['status'] ?? 'unknown', 'last_succeeded_at' => $state['last_succeeded_at'] ?? null], ]; } public function exportRows(array $filters, string $sort, string $direction): iterable { $query = $this->ordered($this->query($filters), $sort, $direction); // Freeze the selected generations in query() so activation cannot mix versions during export. return $this->batches($query); } private function batches(Builder $query): iterable { $batch = []; foreach ($query->cursor() as $row) { $batch[] = $row; if (count($batch) === 500) { yield from $this->mapRows($batch); $batch = []; } } if ($batch !== []) yield from $this->mapRows($batch); } public function filterOptions(): array { $query = $this->query([], true); return [ 'shops' => (clone $query)->whereNotNull('store_name')->where('store_name', '<>', '')->distinct()->orderBy('store_name')->pluck('store_name')->all(), 'bill_items' => (clone $query)->whereNotNull('bill_item')->where('bill_item', '<>', '')->distinct()->orderBy('bill_item')->pluck('bill_item')->all(), ]; } private function query(array $filters, bool $withOptions = false): Builder { $dataset = $this->datasets->get(StoreReceivingAlipayBillMirrorRepository::DATASET); if (! $this->mirror->ready($dataset) || ! $this->mirror->ready($this->datasets->get(StoreReceivingAlipayBillMirrorRepository::SHOP_DATASET))) { throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); } $db = $this->db(); $periods = $db->table('douyin_authoritative_periods')->get(); $legacy = $db->table($dataset->targetTable() . ' as b') ->where('b.channel', StoreReceivingDouyinBillMirrorRepository::SOURCE_CHANNEL); if ($withOptions) { $shops = $db->table('ops_jst_shop_info')->select('shop_id')->selectRaw('MAX(shop_name) AS shop_name')->groupBy('shop_id'); $legacy->leftJoinSub($shops, 's', fn ($join) => $join->on('b.shop_id', '=', 's.shop_id')); } foreach ($periods as $period) { $legacy->where(fn (Builder $q) => $q->where('b.fee_time', '<', $period->period_start) ->orWhere('b.fee_time', '>=', $period->period_end)->orWhereNull('b.fee_time')); } $legacy->selectRaw("CAST(b.Id AS CHAR) AS record_code, b.fee_time AS occurred_at, CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END AS income, CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END AS expense, CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) WHEN b.inout_type = '-1' THEN -ABS(COALESCE(b.amount, 0)) ELSE 0 END AS balance, b.Id AS source_id, NULL AS generation"); $anchor = $db->table('douyin_authoritative_bills') ->whereIn('generation', $periods->pluck('active_generation')->all()) ->select(['record_code', 'occurred_at', 'income', 'expense', 'balance']) ->selectRaw('NULL AS source_id, generation'); if ($withOptions) { $legacy->addSelect(['s.shop_name as store_name', 'b.fee_name as bill_item']); $anchor->addSelect(['store_name', 'bill_item']); } // Apply selective filters before UNION ALL to avoid materializing the full account for a monthly view. $this->filters($legacy, $filters, true); $this->filters($anchor, $filters, false); return $db->query()->fromSub($legacy->unionAll($anchor), 'bills'); } private function filters(Builder $query, array $filters, bool $legacy): void { $date = $legacy ? 'b.fee_time' : 'occurred_at'; $item = $legacy ? 'b.fee_name' : 'bill_item'; if (($filters['store_name'] ?? '') !== '') { $value = '%' . $this->escape($filters['store_name']) . '%'; if ($legacy) { $ids = $this->db()->table('ops_jst_shop_info')->where('shop_name', 'like', $value)->pluck('shop_id')->all(); $query->whereIn('b.shop_id', $ids); } else { $query->where('store_name', 'like', $value); } } if (($filters['bill_item'] ?? '') !== '') $query->where($item, 'like', '%' . $this->escape($filters['bill_item']) . '%'); LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), $item); if (($filters['start'] ?? '') !== '') $query->where($date, '>=', $filters['start'] . ' 00:00:00'); if (($filters['end'] ?? '') !== '') $query->where($date, '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); if (($filters['keyword'] ?? '') !== '') { $keyword = (string) $filters['keyword']; // 纯数字短串按记录编码走索引精确匹配:抖音账户数据量大,包含匹配会全表扫描。 // 页面上关键词框与其它账户页签一致(单输入框),这里按关键词形态自动选路径。 $exactRecord = ($filters['keyword_match'] ?? '') === 'exact-record' || preg_match('/^\d{15,30}$/', $keyword) === 1; if ($exactRecord) { if ($legacy && (! preg_match('/^(0|[1-9]\d{0,9})$/', $keyword) || (float) $keyword > 2147483647)) { $query->whereRaw('1 = 0'); } elseif ($legacy) { $query->where('b.Id', $keyword); } else { // 全数字长串既可能是记录编码也可能是订单号(订单号列无索引,等值扫描可接受)。 $query->where(fn (Builder $q) => $q->where('record_code', $keyword)->orWhere('order_no', $keyword)); } return; } $value = '%' . $this->escape($filters['keyword']) . '%'; $columns = $legacy ? ['b.Id', 'b.auto_id', 'b.fee_name', 'b.primary_key', 'b.p_auto_id', 'b.so_id', 'b.sku_id', 'b.shop_sku_id', 'b.shop_i_id', 'b.channel'] : ['record_code', 'order_no', 'bill_item', 'row_data']; $query->where(function (Builder $q) use ($value, $columns): void { foreach ($columns as $column) $q->orWhere($column, 'like', $value); }); } } private function ordered(Builder $query, string $sort, string $direction): Builder { $sort = in_array($sort, ['record_code', 'occurred_at', 'income', 'expense', 'balance'], true) ? $sort : 'occurred_at'; $direction = $direction === 'asc' ? 'asc' : 'desc'; return $query->orderBy($sort, $direction)->orderBy('record_code', $direction); } private function mapRows(array $rows): array { $ids = array_values(array_filter(array_map(fn ($r) => $r->source_id, $rows), fn ($id) => $id !== null)); $legacy = []; if ($ids !== []) { $raw = $this->db()->table($this->datasets->get(StoreReceivingAlipayBillMirrorRepository::DATASET)->targetTable())->whereIn('Id', $ids)->get()->all(); foreach ($this->source->mapDouyinRows($raw) as $row) $legacy[$row['id']] = $row; } $authoritative = []; foreach (collect($rows)->filter(fn ($row) => $row->source_id === null)->groupBy('generation') as $generation => $group) { $data = $this->db()->table('douyin_authoritative_bills')->where('generation', $generation) ->whereIn('record_code', $group->pluck('record_code')->all())->get(['record_code', 'row_data']); foreach ($data as $row) $authoritative[$generation . ':' . $row->record_code] = json_decode($row->row_data, true, 512, JSON_THROW_ON_ERROR); } return array_map(fn ($row) => $row->source_id === null ? $authoritative[$row->generation . ':' . $row->record_code] : $legacy[(string) $row->source_id], $rows); } private function db(): \Illuminate\Database\Connection { return $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror')); } private function escape(string $value): string { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); } }