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 = $this->summaryForQuery($query, $filters); $total = $summary['count']; $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' => $summary, ]; } /** * @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 = round((float) ($row->income_total ?? 0), 2); $expense = round((float) ($row->expense_total ?? 0), 2); $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 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{balance:float,date:?string,source:string} */ private function openingBalance(array $filters): array { $start = $this->date(isset($filters['start']) ? (string) $filters['start'] : null) ?? $this->date(isset($filters['end']) ? (string) $filters['end'] : null); if ($start === null) { return ['balance' => 0.0, 'date' => null, 'source' => 'none']; } $monthStart = $start->modify('first day of this month')->setTime(0, 0); $balanceDate = $this->connection->table(self::BALANCE_SNAPSHOT_TABLE) ->where('period_end', '<', $monthStart->format('Y-m-d')) ->orderBy('period_end') ->value('period_end'); if ($balanceDate === null) { return ['balance' => 0.0, 'date' => null, 'source' => 'none']; } $store = trim((string) ($filters['store_name'] ?? '')); $snapshotVersion = (string) ($this->connection->table(self::BALANCE_SNAPSHOT_TABLE) ->max('imported_at') ?? ''); $importVersion = (string) ($this->connection->table(self::IMPORT_TABLE) ->where('status', 'succeeded') ->max('updated_at') ?? ''); $cacheKey = 'pdd-opening-balance:v1:' . hash('sha256', implode('|', [ $monthStart->format('Y-m-d'), $balanceDate, $store, $snapshotVersion, $importVersion, ])); $balance = Cache::remember( $cacheKey, self::OPENING_BALANCE_CACHE_SECONDS, function () use ($monthStart, $balanceDate, $store): float { $baseline = $this->connection->table(self::BALANCE_SNAPSHOT_TABLE) ->where('period_end', $balanceDate) ->when( $store !== '', fn (Builder $query) => $query->where( 'normalized_store_name', JushuitanShopBalanceRepository::normalizeStoreName($store), ), ) ->sum('balance'); $movement = $this->connection->table(self::ROW_TABLE) ->where('occurred_at', '>=', $balanceDate . ' 00:00:00') ->where('occurred_at', '<', $monthStart->format('Y-m-d') . ' 00:00:00') ->when($store !== '', fn (Builder $query) => $query->where('store_name', $store)) ->selectRaw('COALESCE(SUM(income_amount - expense_amount), 0) AS net_total') ->first(); return round((float) $baseline + (float) ($movement->net_total ?? 0), 2); }, ); $date = $monthStart->modify('-1 day')->format('Y-m-d'); return [ 'balance' => (float) $balance, 'date' => $date, 'source' => $date === $balanceDate ? 'platform_snapshot' : 'previous_period', ]; } /** @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; } }