>,total:int,current_page:int,last_page:int,per_page:int,summary:array{income:float,expense:float,balance:float,count:int},sync:array} */ public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset) || ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) { throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); $direction = $direction === 'asc' ? 'asc' : 'desc'; $sortColumn = [ 'record_code' => 'b.Id', 'occurred_at' => 'b.fee_time', 'income' => 'b.amount', 'expense' => 'b.amount', 'balance' => 'b.amount', ][$sort] ?? 'b.fee_time'; $query = $this->billQuery(); $this->applyFilters($query, $filters); $state = $this->mirror->state($dataset) ?? []; // The synchronizer persists an exact COUNT in the same transaction that // marks a run successful. Never substitute a database row estimate. $hasFilters = collect($filters)->contains(static fn (mixed $value): bool => $value !== null && trim((string) $value) !== ''); $recordedCount = $state['mirror_row_count'] ?? null; $useRecordedCount = ! $hasFilters && ($state['status'] ?? null) === 'succeeded' && $recordedCount !== null && ctype_digit((string) $recordedCount); $total = $useRecordedCount ? (int) $recordedCount : (clone $query)->count(); $summary = $this->summary($query, $filters); $billRows = $query ->select('b.*') ->orderBy($sortColumn, $direction) ->orderBy('b.Id', $direction) ->forPage($page, $perPage) ->get() ->all(); $shopNames = $this->shopNames($billRows); $rows = collect($billRows) ->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null)) ->all(); $income = $summary['income']; $expense = $summary['expense']; return [ 'data' => $rows, 'total' => $total, 'current_page' => $page, 'last_page' => max((int) ceil($total / $perPage), 1), 'per_page' => $perPage, 'summary' => [ 'income' => $income, 'expense' => $expense, 'balance' => $income - $expense, 'count' => $total, ], 'sync' => [ 'status' => $state['status'] ?? 'unknown', 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, ], ]; } /** @return iterable> */ public function exportRows(array $filters): iterable { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset) || ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) { throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); } $query = $this->billQuery(); $this->applyFilters($query, $filters); $shopNames = $this->allShopNames(); return $query->select('b.*') ->orderBy('b.fee_time')->orderBy('b.Id')->cursor() ->map(fn (object $row): array => $this->mapRow($row, $shopNames[(string) $row->shop_id] ?? null)); } private function billQuery(): Builder { return $this->mirror->connection()->table('ops_jst_original_bill_info as b'); } /** @return array{income:float,expense:float} */ private function summary(Builder $billQuery, array $filters): array { if ($this->dailySummarySupports($filters)) { $summary = $this->dailySummary($filters); if ($summary !== null) { return $summary; } } $row = (clone $billQuery)->selectRaw( "COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, " . "COALESCE(SUM(CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS expense" )->first(); return [ 'income' => round((float) ($row->income ?? 0), 2), 'expense' => round((float) ($row->expense ?? 0), 2), ]; } private function dailySummarySupports(array $filters): bool { return ($filters['alipay_account'] ?? '') === '' && ($filters['bill_item'] ?? '') === '' && ($filters['keyword'] ?? '') === ''; } /** @return array{income:float,expense:float}|null */ private function dailySummary(array $filters): ?array { $connection = $this->mirror->connection(); $schema = Schema::connection($connection->getName()); if (! $schema->hasTable('store_report_import_states') || ! $schema->hasTable('store_report_daily_rows')) { return null; } $state = $connection->table('store_report_import_states') ->where('dataset_key', 'store-report.daily-sales')->first(); $generation = trim((string) ($state->active_generation ?? '')); if ($generation === '' || empty($state->initial_completed_at)) { return null; } $query = $connection->table('store_report_daily_rows')->where('generation', $generation); if (($filters['store_name'] ?? '') !== '') { $query->where('store_name', $filters['store_name']); } if (($filters['start'] ?? '') !== '') { $query->where('occurred_on', '>=', $filters['start']); } if (($filters['end'] ?? '') !== '') { $query->where('occurred_on', '<=', $filters['end']); } $row = $query->selectRaw('COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first(); $income = round((float) ($row->income ?? 0), 2); $expense = round((float) ($row->expense ?? 0), 2); return match ($this->directionCode((string) ($filters['business_type'] ?? ''))) { '1' => ['income' => $income, 'expense' => 0.0], '-1' => ['income' => 0.0, 'expense' => $expense], default => ['income' => $income, 'expense' => $expense], }; } private function applyFilters(Builder $query, array $filters): void { if (($filters['store_name'] ?? '') !== '') { $shopIds = $this->mirror->connection()->table('ops_jst_shop_info') ->where('shop_name', $filters['store_name']) ->pluck('shop_id') ->filter(static fn (mixed $value): bool => $value !== null && $value !== '') ->all(); if ($shopIds === []) { $query->whereRaw('1 = 0'); } else { $query->whereIn('b.shop_id', $shopIds); } } if (($filters['alipay_account'] ?? '') !== '') { $query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['alipay_account']) . '%'); } if (($filters['bill_item'] ?? '') !== '') { $query->where('b.fee_name', $filters['bill_item']); } if (($filters['business_type'] ?? '') !== '') { $query->where('b.inout_type', $this->directionCode((string) $filters['business_type'])); } if (($filters['start'] ?? '') !== '') { $query->where('b.fee_time', '>=', $filters['start'] . ' 00:00:00'); } if (($filters['end'] ?? '') !== '') { $query->where('b.fee_time', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00'); } if (($filters['keyword'] ?? '') !== '') { $value = '%' . $this->escapeLike((string) $filters['keyword']) . '%'; $query->where(function (Builder $query) use ($value): void { foreach (['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'] as $column) { $query->orWhere($column, 'like', $value); } }); } } /** @param array $rows @return array */ private function shopNames(array $rows): array { $shopIds = collect($rows)->pluck('shop_id')->filter()->unique()->values()->all(); if ($shopIds === []) { return []; } return $this->mirror->connection()->table('ops_jst_shop_info') ->whereIn('shop_id', $shopIds)->pluck('shop_name', 'shop_id') ->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name]) ->all(); } /** @return array */ private function allShopNames(): array { return $this->mirror->connection()->table('ops_jst_shop_info') ->whereNotNull('shop_id')->pluck('shop_name', 'shop_id') ->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name]) ->all(); } private function mapRow(object $row, ?string $shopName): array { $amount = abs((float) ($row->amount ?? 0)); $income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0; $expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0; return [ 'id' => (string) $row->Id, 'record_code' => (string) $row->Id, 'store_name' => $shopName, 'custom_remark' => null, 'bill_item' => $row->fee_name, 'alipay_account' => $row->channel, 'account_order_no' => $row->primary_key, 'business_serial_no' => $row->auto_id, 'merchant_order_no' => $row->p_auto_id, 'business_base_order_no' => $row->so_id, 'goods_name' => $row->sku_id ?: $row->shop_sku_id, 'counterparty_account' => null, 'occurred_at' => $this->dateTime($row->fee_time), 'income' => $income, 'expense' => $expense, 'balance' => $income - $expense, 'business_type' => $this->directionName($row->inout_type), 'remark' => null, 'online_order_no' => $row->so_id, 'online_real_store' => $shopName, 'business_description' => $row->combine_type, 'business_bill_source' => $row->channel, 'business_order_no' => $row->p_auto_id, 'created_at' => $this->dateTime($row->createTime), 'updated_at' => $this->dateTime($row->updateTime), 'modifier' => '', ]; } private function directionCode(string $value): string { return match ($value) { '收入', 'IN', 'in' => '1', '支出', 'OUT', 'out' => '-1', default => $value, }; } private function directionName(mixed $value): string { return match ((string) $value) { '1' => '收入', '-1' => '支出', default => (string) ($value ?? ''), }; } private function dateTime(mixed $value): string { return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value)); } private function escapeLike(string $value): string { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); } }