>,total:int,current_page:int,last_page:int,per_page:int,summary:array{amount:float,count:int},sync:array} */ public function paginate(array $filters, int $page, int $perPage): array { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset)) { throw new RuntimeException('bill_offset_mirror_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); // 只有店铺名称、订单号(内部订单号)和款式编码这几类过滤必须依赖关联表; // 只按日期/单据号/商品编码查时,统计与分页都不必把千万级账单表和订单表 // 做全量关联,否则每次翻页都要扫一遍 ops_jst_orders。 $withJoin = $this->requiresJoin($filters); $aggregate = $this->aggregate($filters, $withJoin); $rows = $this->rows($filters, $page, $perPage, $withJoin); $state = $this->mirror->state($dataset) ?? []; return [ 'data' => $rows, 'total' => $aggregate['total'], 'current_page' => $page, 'last_page' => max((int) ceil($aggregate['total'] / $perPage), 1), 'per_page' => $perPage, 'summary' => [ 'amount' => $aggregate['amount'], 'count' => $aggregate['total'], ], 'sync' => [ 'status' => $state['status'] ?? 'unknown', 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, ], ]; } /** * 统计与分页以前各自跑一遍 count/sum 且都带着关联, * 这里合并成一次聚合,并在不需要关联时退回单表。 * * @return array{total:int,amount:float} */ private function aggregate(array $filters, bool $withJoin): array { $query = $withJoin ? $this->query() : $this->billQuery(); $this->applyBillOffsetScope($query); $this->applyFilters($query, $filters); $row = $query->selectRaw( 'COALESCE(SUM(COALESCE(b.amount, 0)), 0) AS amount, COUNT(*) AS aggregate_count' )->first(); return [ 'total' => (int) ($row->aggregate_count ?? 0), 'amount' => round((float) ($row->amount ?? 0), 2), ]; } /** * 取当前页行。不需要关联时先按 (fee_time, Id) 索引定位本页主键, * 再只把这几行拿去关联订单/明细,避免整页数据参与大表 join + filesort。 * * @return list> */ private function rows(array $filters, int $page, int $perPage, bool $withJoin): array { $order = static function (Builder $query): void { $query->orderByDesc('b.fee_time')->orderByDesc('b.Id'); }; if ($withJoin) { $query = $this->query(); $this->applyBillOffsetScope($query); $this->applyFilters($query, $filters); $order($query); return $query->select($this->columns()) ->forPage($page, $perPage) ->get() ->map(fn (object $row): array => $this->mapRow($row)) ->all(); } $probe = $this->billQuery()->select('b.Id'); $this->applyBillOffsetScope($probe); $this->applyFilters($probe, $filters); $order($probe); $ids = $probe->forPage($page, $perPage)->pluck('Id')->all(); if ($ids === []) { return []; } $query = $this->query()->whereIn('b.Id', $ids); $order($query); return $query->select($this->columns()) ->get() ->map(fn (object $row): array => $this->mapRow($row)) ->all(); } /** 判断本组过滤条件是否有任何一项必须靠关联表才能成立。 */ private function requiresJoin(array $filters): bool { $orders = $this->ordersAvailable(); if ($orders && ($filters['shop_name'] ?? '') !== '') { return true; } if ($orders && ($filters['order_no'] ?? '') !== '') { return true; } return $orders && $this->orderItemsAvailable() && ($filters['sku_id'] ?? '') !== ''; } private function billQuery(): Builder { return $this->mirror->connection()->table('ops_jst_original_bill_info as b'); } private function query(): Builder { $query = $this->billQuery(); $ordersAvailable = $this->ordersAvailable(); if ($ordersAvailable) { $query->leftJoin('ops_jst_orders as o', function ($join): void { $join->on('o.so_id', '=', 'b.so_id'); }); } if ($ordersAvailable && $this->orderItemsAvailable()) { $query->leftJoin('ops_jst_order_item as i', function ($join): void { $join->on('i.o_id', '=', 'o.o_id') ->whereColumn('i.sku_id', 'b.sku_id'); }); } return $query; } private function hasTable(string $table): bool { return Schema::connection($this->mirror->connection()->getName())->hasTable($table); } private function ordersAvailable(): bool { return $this->hasTable('ops_jst_orders'); } private function orderItemsAvailable(): bool { return $this->hasTable('ops_jst_order_item'); } private function applyFilters(Builder $query, array $filters): void { if (($filters['from'] ?? '') !== '') { $query->where('b.fee_time', '>=', $filters['from'] . ' 00:00:00'); } if (($filters['to'] ?? '') !== '') { $query->where('b.fee_time', '<', date('Y-m-d 00:00:00', strtotime($filters['to'] . ' +1 day'))); } if (($filters['order_no'] ?? '') !== '') { $needle = $this->contains((string) $filters['order_no']); $query->where(function (Builder $order) use ($needle): void { $order->whereRaw("CAST(b.so_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]) ->orWhereRaw("CAST(b.p_auto_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]); if ($this->ordersAvailable()) { $order->orWhereRaw("CAST(o.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle]); } }); } if (($filters['shop_name'] ?? '') !== '') { if ($this->ordersAvailable()) { $query->whereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains((string) $filters['shop_name'])]); } else { $query->whereRaw('1 = 0'); } } if (($filters['sku_id'] ?? '') !== '') { $needle = $this->contains((string) $filters['sku_id']); $query->where(function (Builder $sku) use ($needle): void { foreach (['b.sku_id', 'b.shop_sku_id', 'b.shop_i_id'] as $column) { $sku->orWhereRaw($column . " LIKE ? ESCAPE '!'", [$needle]); } if ($this->ordersAvailable() && $this->orderItemsAvailable()) { $sku->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]); } }); } } private function applyBillOffsetScope(Builder $query): void { // 原始账单镜像供多个业务复用,这个页面只呈现 500112 冲减收入。 // 兼容上游历史上仅保存名称、不带科目编号的“冲减收入”写法。 $query->where(function (Builder $offset): void { $offset->whereRaw("b.fee_name LIKE ? ESCAPE '!'", ['%500112%']) ->orWhereRaw("b.fee_name LIKE ? ESCAPE '!'", ['%冲减收入%']); }); } /** @return list */ private function columns(): array { $columns = [ 'b.Id as record_id', 'b.fee_time', 'b.fee_name', 'b.inout_type', 'b.amount', 'b.so_id as bill_order_no', 'b.sku_id as bill_product_code', 'b.shop_sku_id as bill_shop_product_code', 'b.shop_i_id as bill_shop_style_code', ]; if ($this->ordersAvailable()) { $columns = [...$columns, 'o.o_id as internal_order_no', 'o.so_id as online_order_no', 'o.shop_name', 'o.shop_id as shop_code']; } if ($this->ordersAvailable() && $this->orderItemsAvailable()) { $columns = [...$columns, 'i.sku_id as product_code', 'i.i_id as style_code', 'i.shop_sku_id as shop_product_code', 'i.shop_i_id as shop_style_code']; } return $columns; } /** @return array */ private function mapRow(object $row): array { return [ 'record_id' => (string) ($row->record_id ?? ''), 'fee_time' => $this->dateTime($row->fee_time ?? null), 'fee_name' => $row->fee_name ?? null, 'inout_type' => $row->inout_type ?? null, 'internal_order_no' => $row->internal_order_no ?? $row->bill_order_no ?? null, 'online_order_no' => $row->online_order_no ?? $row->bill_order_no ?? null, 'shop_name' => $row->shop_name ?? null, 'shop_code' => $row->shop_code ?? null, 'product_code' => $row->product_code ?? $row->bill_product_code ?? null, 'style_code' => $row->style_code ?? null, 'shop_product_code' => $row->shop_product_code ?? $row->bill_shop_product_code ?? null, 'shop_style_code' => $row->shop_style_code ?? $row->bill_shop_style_code ?? null, 'amount' => round((float) ($row->amount ?? 0), 2), ]; } private function dateTime(mixed $value): string { return $value === null || $value === '' ? '' : date('Y-m-d H:i:s', strtotime((string) $value)); } private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; } }