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 string[] */ public function storeOptions(): array { return $this->query()->whereNotNull('store_name')->where('store_name', '<>', '') ->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all(); } /** @return array{shops:list,bill_items:list} */ public function accountBillFilterOptions(): array { $shops = $this->query()->whereNotNull('store_name')->where('store_name', '<>', '') ->distinct()->orderBy('store_name')->pluck('store_name') ->map(static fn (mixed $value): string => (string) $value)->all(); $billItems = $this->query()->whereNotNull('bill_item')->where('bill_item', '<>', '') ->distinct()->orderBy('bill_item')->pluck('bill_item') ->map(static fn (mixed $value): string => (string) $value)->all(); return ['shops' => $shops, 'bill_items' => $billItems]; } /** @return array{rows:list>,total:int} */ public function paginate( ?string $storeName, ?string $fromDate, ?string $toDate, ?string $flowType, int $page, int $pageSize, ): array { $page = max(1, $page); $pageSize = min(max(1, $pageSize), 500); $query = $this->query(); $this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType); $total = (clone $query)->count(); $rows = $query->orderByDesc('occurred_at')->orderByDesc('record_code') ->forPage($page, $pageSize)->get()->map(function (object $row): array { $signed = (float) $row->signed_amount; $income = (float) $row->income_amount; $expense = (float) $row->expense_amount; $flowType = $income > 0 ? 1 : ($expense > 0 ? 2 : 0); return [ 'id' => $row->record_code, 'shopName' => (string) ($row->store_name ?? ''), 'amount' => abs($signed), 'balance' => null, 'summary' => (string) ($row->bill_item ?? ''), 'flowType' => $flowType, 'toAdvertiserFundType' => null, 'createTime' => (string) $row->occurred_at, 'source_primary_key' => (string) ($row->merchant_order_no ?? ''), 'source_record_code' => (string) $row->record_code, 'source_channel' => 'PinduoduoSettlementBill', ]; })->all(); return ['rows' => $rows, 'total' => $total]; } /** @return array{income_total:float,expense_total:float,cnt:int} */ public function summarize( ?string $storeName, ?string $fromDate, ?string $toDate, ?string $flowType, ): array { $query = $this->query(); $this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType); $row = $query->selectRaw( 'COALESCE(SUM(income_amount), 0) AS income_total, ' . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS cnt' )->first(); return [ 'income_total' => (float) ($row->income_total ?? 0), 'expense_total' => (float) ($row->expense_total ?? 0), 'cnt' => (int) ($row->cnt ?? 0), ]; } /** @return array */ public function paginateAccountBills( 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->applyAccountBillFilters($query, $filters); $summary = (clone $query)->selectRaw( 'COALESCE(SUM(income_amount), 0) AS income_total, ' . 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count' )->first(); $total = (int) ($summary->row_count ?? 0); $income = (float) ($summary->income_total ?? 0); $expense = (float) ($summary->expense_total ?? 0); $sortColumn = $this->accountBillSortColumn($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->mapAccountBillRow($row))->all(); return [ 'data' => $rows, 'total' => $total, 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage, 'from' => $total === 0 ? null : (($page - 1) * $perPage) + 1, 'to' => $total === 0 ? null : min($page * $perPage, $total), 'summary' => [ 'income' => $income, 'expense' => $expense, 'balance' => round($income - $expense, 2), 'count' => $total, ], ]; } /** @return iterable> */ public function exportAccountBills(array $filters, string $sort, string $direction): iterable { $query = $this->query(); $this->applyAccountBillFilters($query, $filters); $sortColumn = $this->accountBillSortColumn($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->mapAccountBillRow($row)); } private function query(): Builder { if (! $this->ready()) { throw new RuntimeException('pinduoduo_authoritative_bills_not_ready'); } return $this->connection->table(self::ROW_TABLE); } private function applyFilters( Builder $query, ?string $storeName, ?string $fromDate, ?string $toDate, ?string $flowType, ): void { if ($storeName !== null && $storeName !== '') { $query->where('store_name', $storeName); } $from = $this->date($fromDate); if ($from !== null) { $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); } $to = $this->date($toDate); if ($to !== null) { $query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00'); } if ($flowType === 'income') { $query->where('income_amount', '>', 0); } elseif ($flowType === 'expense') { $query->where('expense_amount', '>', 0); } } /** @param array $filters */ private function applyAccountBillFilters(Builder $query, array $filters): void { $storeName = trim((string) ($filters['store_name'] ?? '')); if ($storeName !== '') { $query->where('store_name', $storeName); } $billItem = trim((string) ($filters['bill_item'] ?? '')); if ($billItem !== '') { $query->where('bill_item', $billItem); } $this->applyLedgerRemarkFilter($query, $filters); $from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null); if ($from !== null) { $query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00'); } $to = $this->date(isset($filters['end']) ? (string) $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 !== '') { $query->where(function (Builder $nested) use ($keyword): void { $like = '%' . $keyword . '%'; $nested->where('record_code', 'like', $like) ->orWhere('merchant_order_no', 'like', $like) ->orWhere('online_order_no', 'like', $like) ->orWhere('remark', 'like', $like) ->orWhere('custom_remark', 'like', $like) ->orWhere('business_description', 'like', $like) ->orWhere('bill_item', 'like', $like); }); } } /** 「总账备注」筛选:翻译成账单项目集合(拼多多单平台)。 */ private function applyLedgerRemarkFilter(Builder $query, array $filters): void { $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 . '%'), ); return; } LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'bill_item'); } private function accountBillSortColumn(string $sort): string { return [ 'occurred_at' => 'occurred_at', 'income' => 'income_amount', 'expense' => 'expense_amount', 'record_code' => 'record_code', ][$sort] ?? 'occurred_at'; } /** @return array */ private function mapAccountBillRow(object $row): array { $signedAmount = (float) $row->signed_amount; return [ 'id' => (string) $row->record_code, 'record_code' => (string) $row->record_code, 'store_name' => (string) ($row->store_name ?? ''), 'finance_remark' => (string) ($row->business_description ?? ''), 'order_no' => (string) ($row->merchant_order_no ?? ''), 'amount' => abs($signedAmount), 'custom_remark' => (string) ($row->custom_remark ?? ''), 'bill_item' => (string) ($row->bill_item ?? ''), 'occurred_at' => (string) $row->occurred_at, 'income' => (float) $row->income_amount, 'expense' => (float) $row->expense_amount, 'balance' => $signedAmount, 'remark' => (string) ($row->remark ?? ''), 'updated_at' => $row->source_updated_at !== null ? (string) $row->source_updated_at : null, 'modifier' => (string) ($row->source_modified_by ?? ''), 'payload' => [ 'merchant_order_no' => (string) ($row->merchant_order_no ?? ''), 'online_order_no' => (string) ($row->online_order_no ?? ''), 'finance_type' => (string) ($row->business_type ?? ''), 'business_description' => (string) ($row->business_description ?? ''), 'type' => (string) ($row->account_name ?? ''), ], ]; } private function date(?string $value): ?DateTimeImmutable { if ($value === null || 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; } }