connection = $databases->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } public function ready(): bool { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable(self::IMPORT_TABLE) || ! $schema->hasTable(self::ROW_TABLE)) { return false; } return $this->connection->table(self::IMPORT_TABLE)->where('status', 'succeeded')->exists(); } /** @return array{shops:list,bill_items:list} */ public function accountBillFilterOptions(): array { $query = $this->query(); return [ 'shops' => (clone $query)->whereNotNull('store_name')->where('store_name', '<>', '') ->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all(), 'bill_items' => (clone $query)->whereNotNull('bill_item')->where('bill_item', '<>', '') ->distinct()->orderBy('bill_item')->pluck('bill_item')->map('strval')->all(), ]; } /** @return array */ public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array { $page = max(1, $page); $perPage = min(max(1, $perPage), 100); $query = $this->query(); $this->applyFilters($query, $filters); $summary = $this->summaryForQuery($query, $filters); $total = $summary['count']; $sortColumn = $this->sortColumn($sort); $direction = $direction === 'asc' ? 'asc' : 'desc'; $query->orderBy($sortColumn, $direction); if ($sortColumn !== 'record_code') $query->orderBy('record_code', $direction); $rows = $query->forPage($page, $perPage)->get() ->map(fn (object $row): array => $this->mapRow($row))->all(); return [ 'data' => $rows, 'total' => $total, 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage, 'summary' => $summary, 'sync' => [ 'status' => 'succeeded', 'last_succeeded_at' => $this->connection->table(self::IMPORT_TABLE) ->where('status', 'succeeded')->max('completed_at'), ], ]; } /** * 当前筛选条件的账户余额汇总。 * * 淘系微信页签本身没有余额列,直接复用这里的期初、收支和期末口径, * 避免两个页签对同一账期计算出不同的账户余额。 * * @return array{income:float,expense:float,opening_balance:float,opening_balance_date:?string,opening_balance_source:string,balance:float,count:int} */ public function summary(array $filters): array { $query = $this->query(); $this->applyFilters($query, $filters); return $this->summaryForQuery($query, $filters); } /** * @return array{income:float,expense:float,opening_balance:float,opening_balance_date:?string,opening_balance_source:string,balance:float,count:int} */ private function summaryForQuery(Builder $query, array $filters): array { $row = (clone $query)->selectRaw( 'COALESCE(SUM(income_amount), 0) AS income_total, ' . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count' )->first(); $income = (float) ($row->income_total ?? 0); $expense = (float) ($row->expense_total ?? 0); $opening = $this->openingBalance($filters); return [ 'income' => $income, 'expense' => $expense, 'opening_balance' => $opening['balance'], 'opening_balance_date' => $opening['date'], 'opening_balance_source' => $opening['source'], 'balance' => round($opening['balance'] + $income - $expense, 2), 'count' => (int) ($row->row_count ?? 0), ]; } /** @return iterable> */ public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable { $query = $this->query(); $this->applyFilters($query, $filters); $sortColumn = $this->sortColumn($sort); $direction = $direction === 'desc' ? 'desc' : 'asc'; $query->orderBy($sortColumn, $direction); if ($sortColumn !== 'record_code') $query->orderBy('record_code', $direction); return $query->cursor()->map(fn (object $row): array => $this->mapRow($row)); } private function query(): Builder { if (! $this->ready()) { throw new RuntimeException('taobao_alipay_authoritative_bills_not_ready'); } return $this->connection->table(self::ROW_TABLE); } /** @param array $filters */ private function applyFilters(Builder $query, array $filters): void { $store = trim((string) ($filters['store_name'] ?? '')); if ($store !== '') $query->where('store_name', $store); $channel = trim((string) ($filters['source_channel'] ?? '')); if ($channel !== '') $query->where('business_bill_source', 'like', '%' . $this->escapeLike($channel) . '%'); $item = trim((string) ($filters['bill_item'] ?? '')); if ($item !== '') $query->where('bill_item', $item); // 「总账备注」:淘系支付宝页签同页混有天猫/淘宝/阿里巴巴店铺,按店铺名前缀分平台过滤。 $groups = is_array($filters['bill_item_groups'] ?? null) ? $filters['bill_item_groups'] : []; if ($groups !== []) { LedgerRemarkFilter::applyGroups( $query, $groups, 'bill_item', static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'), static fn (Builder $sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'), ); } else { LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'bill_item'); } $type = trim((string) ($filters['business_type'] ?? '')); if ($type === '收入') $query->where('income_amount', '>', 0); if ($type === '支出') $query->where('expense_amount', '>', 0); $from = $this->date($filters['start'] ?? null); if ($from !== null) $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); $to = $this->date($filters['end'] ?? null); if ($to !== null) $query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00'); $keyword = trim((string) ($filters['keyword'] ?? '')); if ($keyword !== '') { $like = '%' . $this->escapeLike($keyword) . '%'; $query->where(function (Builder $nested) use ($like): void { foreach ([ 'record_code', 'account_order_no', 'business_serial_no', 'merchant_order_no', 'business_base_order_no', 'online_order_no', 'business_order_no', 'remark', 'custom_remark', 'business_description', 'bill_item', 'goods_name', ] as $column) { $method = $column === 'record_code' ? 'where' : 'orWhere'; $nested->{$method}($column, 'like', $like); } }); } } private function sortColumn(string $sort): string { return [ 'occurred_at' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'balance' => 'balance_amount', 'record_code' => 'record_code', ][$sort] ?? 'occurred_at'; } /** * 当前账期开始前,每个支付宝账号最后一笔余额之和。 * * 同一秒可能有多笔流水,按源表行号继续取最后一笔,避免同一时间点重复计入。 * * @return array{balance:float,date:?string,source:string} */ private function openingBalance(array $filters): array { $start = $this->date($filters['start'] ?? null) ?? $this->date($filters['end'] ?? null); if ($start === null) { return ['balance' => 0.0, 'date' => null, 'source' => 'none']; } $monthStart = $start->modify('first day of this month'); $nextMonthStart = $monthStart->modify('+1 month'); $cutoff = $monthStart->format('Y-m-d') . ' 00:00:00'; $periodEnd = $nextMonthStart->format('Y-m-d') . ' 00:00:00'; $store = trim((string) ($filters['store_name'] ?? '')); $importVersion = (string) ($this->connection->table(self::IMPORT_TABLE) ->where('status', 'succeeded') ->max('completed_at') ?? ''); $cacheKey = 'taobao-alipay-opening-balance:v1:' . hash('sha256', implode('|', [ $cutoff, $store, $importVersion, ])); $opening = Cache::remember( $cacheKey, self::OPENING_BALANCE_CACHE_SECONDS, function () use ($cutoff, $periodEnd, $store): array { $ranked = $this->connection->table(self::ROW_TABLE) ->where('occurred_at', '<', $cutoff) ->whereNotNull('alipay_account') ->where('alipay_account', '<>', '') ->when($store !== '', fn (Builder $query) => $query->where('store_name', $store)) ->select([ 'alipay_account', 'occurred_at', 'source_row_number', 'income_amount', 'expense_amount', 'balance_amount', ]) ->selectRaw( 'ROW_NUMBER() OVER (' . 'PARTITION BY alipay_account ' . 'ORDER BY occurred_at DESC, source_row_number DESC' . ') AS row_rank', ); $latestRows = $this->connection->query() ->fromSub($ranked, 'latest_balances') ->where('row_rank', 1) ->get(); if ($latestRows->isNotEmpty()) { return [ 'balance' => round((float) $latestRows->sum('balance_amount'), 2), 'source' => 'previous_period', ]; } // 首个导入月份没有上月快照时,用当月首笔余额倒推期初: // 期初 = 首笔余额 - 首笔收入 + 首笔支出。 $firstRows = $this->connection->table(self::ROW_TABLE) ->where('occurred_at', '>=', $cutoff) ->where('occurred_at', '<', $periodEnd) ->whereNotNull('alipay_account') ->where('alipay_account', '<>', '') ->when($store !== '', fn (Builder $query) => $query->where('store_name', $store)) ->select([ 'alipay_account', 'occurred_at', 'source_row_number', 'income_amount', 'expense_amount', 'balance_amount', ]) ->selectRaw( 'ROW_NUMBER() OVER (' . 'PARTITION BY alipay_account ' . 'ORDER BY occurred_at ASC, source_row_number ASC' . ') AS row_rank', ); $first = $this->connection->query() ->fromSub($firstRows, 'first_balances') ->where('row_rank', 1) ->get(); return [ 'balance' => round((float) $first->sum( static fn (object $row): float => (float) $row->balance_amount - (float) $row->income_amount + (float) $row->expense_amount, ), 2), 'source' => 'first_row_estimate', ]; }, ); return [ 'balance' => (float) $opening['balance'], 'date' => $monthStart->modify('-1 day')->format('Y-m-d'), 'source' => (string) $opening['source'], ]; } /** @return array */ private function mapRow(object $row): array { return [ 'id' => (string) $row->record_code, 'record_code' => (string) $row->record_code, 'store_name' => (string) ($row->store_name ?? ''), 'custom_remark' => (string) ($row->custom_remark ?? ''), 'bill_item' => (string) ($row->bill_item ?? ''), 'alipay_account' => (string) ($row->alipay_account ?? ''), 'account_order_no' => (string) ($row->account_order_no ?? ''), 'business_serial_no' => (string) ($row->business_serial_no ?? ''), 'merchant_order_no' => (string) ($row->merchant_order_no ?? ''), 'business_base_order_no' => (string) ($row->business_base_order_no ?? ''), 'goods_name' => (string) ($row->goods_name ?? ''), 'counterparty_account' => (string) ($row->counterparty_account ?? ''), 'occurred_at' => (string) $row->occurred_at, 'income' => (float) $row->income_amount, 'expense' => (float) $row->expense_amount, 'balance' => (float) $row->balance_amount, 'business_type' => (string) ($row->business_type ?? ''), 'remark' => (string) ($row->remark ?? ''), 'online_order_no' => (string) ($row->online_order_no ?? ''), 'online_real_store' => (string) ($row->online_real_store ?? ''), 'business_description' => (string) ($row->business_description ?? ''), 'business_bill_source' => (string) ($row->business_bill_source ?? ''), 'business_order_no' => (string) ($row->business_order_no ?? ''), 'created_at' => $row->source_created_at !== null ? (string) $row->source_created_at : null, 'updated_at' => $row->source_updated_at !== null ? (string) $row->source_updated_at : null, 'modifier' => (string) ($row->source_modified_by ?? ''), ]; } private function date(mixed $value): ?DateTimeImmutable { $value = trim((string) ($value ?? '')); if ($value === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) return null; $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); return $date !== false && $date->format('Y-m-d') === $value ? $date : null; } private function escapeLike(string $value): string { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); } }