>,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 { if ($this->authoritative()->ready()) { return $this->authoritative()->paginate($filters, $sort, $direction, $page, $perPage); } return $this->paginateChannel($filters, $sort, $direction, $page, $perPage, 'alipayCSV', 'taobao', self::DATASET, true); } /** * Read one upstream platform channel from the shared original-bill mirror. * The mirror is shared by several account pages; callers must provide a * fixed source channel rather than accepting arbitrary SQL/filter input. * * @return array{data:array>,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 paginateChannel( array $filters, string $sort, string $direction, int $page, int $perPage, string|array|null $channel, string $mapping = 'taobao', string $datasetKey = self::DATASET, bool $excludeNonTaobaoPlatforms = false, ): array { $dataset = $this->datasets->get($datasetKey); 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' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : ($mapping === 'taobao' ? 'b.p_auto_id' : 'b.Id'), 'occurred_at' => 'b.fee_time', 'income' => 'b.amount', 'expense' => 'b.amount', 'balance' => 'b.amount', ][$sort] ?? 'b.fee_time'; $targetTable = $dataset->targetTable(); $query = $this->billQuery($targetTable); $this->applyChannel($query, $channel, $dataset->fixedFilters()); if ($excludeNonTaobaoPlatforms) { $this->applyTaobaoPlatformScope($query); } $this->applyFilters($query, $filters); $state = $this->mirror->state($dataset) ?? []; // Completed runs persist an exact COUNT. New incremental batches keep // that count exact in their upsert transaction, including inserts. $hasFilters = collect($filters)->contains(static fn (mixed $value): bool => $value !== null && trim((string) $value) !== ''); $recordedCount = $state['mirror_row_count'] ?? null; $useRecordedCount = ! $excludeNonTaobaoPlatforms && $this->channelCoversDataset($channel, $dataset->fixedFilters()) && ! $hasFilters && (($state['status'] ?? null) === 'succeeded' || (bool) ($state['mirror_row_count_is_exact'] ?? false)) && $recordedCount !== null && ctype_digit((string) $recordedCount); $summary = $this->rememberAggregateSummary( $this->aggregateCacheKey( $datasetKey, $filters, $channel, $excludeNonTaobaoPlatforms, ), fn (): array => $this->summary($query, $filters, $channel, ! $useRecordedCount), ); $total = $useRecordedCount ? (int) $recordedCount : ($summary['count'] ?? $this->boundedCount(clone $query)); $billRows = $this->pageRows( $query, $sortColumn, $direction, $page, $perPage, $filters, $channel, $targetTable, ); $shopNames = $this->shopNames($billRows); $productNames = $mapping === 'taobao' ? $this->productNames($billRows) : []; $importedRows = $mapping === 'taobao' ? $this->importedRows($billRows) : []; $rows = collect($billRows) ->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping, $productNames, $importedRows)) ->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' => round($income - $expense, 2), 'count' => $total, ], 'sync' => [ 'status' => $state['status'] ?? 'unknown', 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, ], ]; } /** @return iterable> */ public function exportRows(array $filters, string $sort = 'occurred_at', string $direction = 'asc'): iterable { if ($this->authoritative()->ready()) { return $this->authoritative()->exportRows($filters, $sort, $direction); } return $this->exportChannelRows($filters, $sort, $direction, 'alipayCSV', 'taobao', self::DATASET, true); } /** @return iterable> */ public function exportChannelRows( array $filters, string $sort = 'occurred_at', string $direction = 'asc', string|array|null $channel = null, string $mapping = 'taobao', string $datasetKey = self::DATASET, bool $excludeNonTaobaoPlatforms = false, ): iterable { $dataset = $this->datasets->get($datasetKey); if (! $this->mirror->ready($dataset) || ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) { throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); } $targetTable = $dataset->targetTable(); $query = $this->billQuery($targetTable); $this->applyChannel($query, $channel, $dataset->fixedFilters()); if ($excludeNonTaobaoPlatforms) { $this->applyTaobaoPlatformScope($query); } $this->applyFilters($query, $filters); $shopNames = $this->allShopNames(); $direction = $direction === 'desc' ? 'desc' : 'asc'; $sortColumn = [ 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : ($mapping === 'taobao' ? 'b.p_auto_id' : 'b.Id'), 'occurred_at' => 'b.fee_time', 'income' => 'b.amount', 'expense' => 'b.amount', 'balance' => 'b.amount', ][$sort] ?? 'b.fee_time'; return $this->exportBatches($query, $sortColumn, $direction, $shopNames, $mapping, $targetTable); } /** @return array{shops:array,bill_items:array} */ public function filterOptionsForChannel(string|array $channel, string $datasetKey = self::DATASET, bool $excludeNonTaobaoPlatforms = false): array { if ($channel === 'alipayCSV' && $datasetKey === self::DATASET && $excludeNonTaobaoPlatforms && $this->authoritative()->ready()) { return $this->authoritative()->accountBillFilterOptions(); } $dataset = $this->datasets->get($datasetKey); if (! $this->mirror->ready($dataset) || ! $this->mirror->ready($this->datasets->get(self::SHOP_DATASET))) { throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready'); } return $this->rememberFilterOptions( $this->filterOptionsCacheKey($datasetKey, $channel, $excludeNonTaobaoPlatforms), function () use ($dataset, $channel, $excludeNonTaobaoPlatforms): array { $channelQuery = $this->billQuery($dataset->targetTable()); $this->applyChannel($channelQuery, $channel, $dataset->fixedFilters()); if ($excludeNonTaobaoPlatforms) { $this->applyTaobaoPlatformScope($channelQuery); } $shopIds = (clone $channelQuery) ->whereNotNull('b.shop_id') ->distinct() ->pluck('b.shop_id') ->filter(static fn (mixed $value): bool => $value !== null && $value !== '') ->all(); $shops = $shopIds === [] ? collect() : $this->mirror->connection()->table('ops_jst_shop_info') ->whereIn('shop_id', $shopIds) ->whereNotNull('shop_name') ->orderBy('shop_name') ->pluck('shop_name'); $billItems = (clone $channelQuery) ->whereNotNull('b.fee_name') ->where('b.fee_name', '<>', '') ->select('b.fee_name') ->distinct() ->orderBy('b.fee_name') ->pluck('b.fee_name'); $normalize = static fn ($values): array => collect($values) ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->unique() ->values() ->all(); return [ 'shops' => $normalize($shops), 'bill_items' => $normalize($billItems), ]; }, ); } /** Read bounded pages so a full export does not sort/buffer millions of rows at once. */ private function exportBatches(Builder $query, string $sortColumn, string $direction, array $shopNames, string $mapping, string $targetTable): iterable { $operator = $direction === 'asc' ? '>' : '<'; $sortField = substr($sortColumn, 2); $last = null; $maximumId = $this->billQuery($targetTable)->max('b.Id'); if ($maximumId === null) return; do { $batch = (clone $query)->where('b.Id', '<=', $maximumId); if ($last !== null) { if ($sortColumn === 'b.Id') { $batch->where('b.Id', $operator, $last->Id); } else { $value = $last->{$sortField}; $batch->where(function (Builder $after) use ($sortColumn, $direction, $operator, $last, $value): void { if ($value === null) { $after->where(function (Builder $ties) use ($sortColumn, $operator, $last): void { $ties->whereNull($sortColumn)->where('b.Id', $operator, $last->Id); }); if ($direction === 'asc') $after->orWhereNotNull($sortColumn); } else { $after->where($sortColumn, $operator, $value) ->orWhere(function (Builder $ties) use ($sortColumn, $operator, $last, $value): void { $ties->where($sortColumn, $value)->where('b.Id', $operator, $last->Id); }); if ($direction === 'desc') $after->orWhereNull($sortColumn); } }); } } $rows = $batch->select('b.*')->orderBy($sortColumn, $direction) ->orderBy('b.Id', $direction)->limit(1000)->get(); $productNames = $mapping === 'taobao' ? $this->productNames($rows->all()) : []; $importedRows = $mapping === 'taobao' ? $this->importedRows($rows->all()) : []; foreach ($rows as $row) { yield $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping, $productNames, $importedRows); } $last = $rows->last(); } while ($rows->count() === 1000); } private function billQuery(string $targetTable): Builder { return $this->mirror->connection()->table($targetTable . ' as b'); } private function authoritative(): TaobaoAlipaySettlementBillRepository { return $this->authoritative ??= app(TaobaoAlipaySettlementBillRepository::class); } /** @return array */ private function pageRows( Builder $query, string $sortColumn, string $direction, int $page, int $perPage, array $filters, string|array|null $channel, string $targetTable, ): array { if ($channel === null || ($filters['bill_item'] ?? '') === '') { $pageQuery = $query->select('b.*') ->orderBy($sortColumn, $direction) ->orderBy('b.Id', $direction) ->forPage($page, $perPage); return $this->boundedGet($pageQuery)->all(); } $idQuery = clone $query; if ($targetTable === 'ops_jst_original_bill_info' && $idQuery->getConnection()->getDriverName() === 'mysql') { $idQuery->forceIndex('idx_original_bill_channel_shop'); } $idQuery->select('b.Id') ->orderBy($sortColumn, $direction) ->orderBy('b.Id', $direction) ->forPage($page, $perPage); $ids = $this->boundedGet($idQuery) ->pluck('Id') ->all(); if ($ids === []) { return []; } $rowsById = $this->boundedGet( $this->billQuery($targetTable)->whereIn('b.Id', $ids)->select('b.*'), ) ->keyBy(static fn (object $row): string => (string) $row->Id); return collect($ids) ->map(static fn (mixed $id): ?object => $rowsById->get((string) $id)) ->filter() ->values() ->all(); } /** @return array{income:float,expense:float,count:?int} */ private function summary( Builder $billQuery, array $filters, string|array|null $channel = null, bool $includeCount = true, ): array { if ($channel === null && $this->dailySummarySupports($filters)) { $summary = $this->dailySummary($filters); if ($summary !== null) { return $summary + ['count' => null]; } } $aggregate = (clone $billQuery)->selectRaw( ($includeCount ? 'COUNT(*) AS aggregate_count, ' : '') . "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" ); $row = $this->boundedGet($aggregate)->first(); return [ 'income' => round((float) ($row->income ?? 0), 2), 'expense' => round((float) ($row->expense ?? 0), 2), 'count' => $includeCount ? (int) ($row->aggregate_count ?? 0) : null, ]; } private function dailySummarySupports(array $filters): bool { return ($filters['source_channel'] ?? '') === '' && ($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', 'like', '%' . $this->escapeLike((string) $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['source_channel'] ?? '') !== '') { $query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['source_channel']) . '%'); } if (($filters['bill_item'] ?? '') !== '') { $query->where('b.fee_name', 'like', '%' . $this->escapeLike((string) $filters['bill_item']) . '%'); } // 「总账备注」:共享原始账单镜像只有 shop_id,平台归属用店铺资料表的店铺名前缀判定。 $groups = is_array($filters['bill_item_groups'] ?? null) ? $filters['bill_item_groups'] : []; if ($groups !== []) { LedgerRemarkFilter::applyGroups( $query, $groups, 'b.fee_name', fn (Builder $sub, string $prefix) => $sub->whereIn('b.shop_id', $this->shopIdsLike($prefix)), fn (Builder $sub, string $prefix) => $sub->whereNotIn('b.shop_id', $this->shopIdsLike($prefix)), ); } else { LedgerRemarkFilter::itemsWhereIn($query, (array) ($filters['bill_items'] ?? []), 'b.fee_name'); } 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'] ?? '') !== '') { if (($filters['keyword_match'] ?? '') === 'exact-record') { $keyword = (string) $filters['keyword']; if (! preg_match('/^(0|[1-9]\d{0,9})$/', $keyword) || (float) $keyword > 2147483647) { $query->whereRaw('1 = 0'); } else { $query->where('b.Id', $keyword); } return; } $value = '%' . $this->escapeLike((string) $filters['keyword']) . '%'; $query->where(function (Builder $query) use ($value): void { foreach (['b.Id', 'b.auto_id', '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> $fixedFilters */ private function applyChannel(Builder $query, string|array|null $channel, array $fixedFilters): void { if ($channel === null || $channel === '') return; if (is_string($channel)) { if (! $this->channelCoversDataset($channel, $fixedFilters)) { $query->where('b.channel', $channel); } return; } $channels = array_values($channel); if ($channels === []) { $query->whereRaw('1 = 0'); return; } $query->whereIn('b.channel', $channels); } private function applyTaobaoPlatformScope(Builder $query): void { $excludedShopIds = $this->mirror->connection()->table('ops_jst_shop_info') ->where(function (Builder $scope): void { foreach (self::EXCLUDED_TAOBAO_SHOP_SITE_PREFIXES as $prefix) { $scope->orWhere('shop_site', 'like', $prefix . '%'); } })->select('shop_id'); // Unknown shop metadata is retained: without a platform label it cannot // be identified as one of the explicitly excluded platforms. $query->whereNotIn('b.shop_id', $excludedShopIds); } /** 店铺名前缀 → shop_id 子查询(共享原始账单镜像按店铺资料表判定平台)。 */ private function shopIdsLike(string $prefix): Builder { return $this->mirror->connection()->table('ops_jst_shop_info') ->where('shop_name', 'like', $this->escapeLike($prefix) . '%') ->select('shop_id'); } /** @param array> $fixedFilters */ private function channelCoversDataset(string|array|null $channel, array $fixedFilters): bool { if ($channel === null || $channel === '') { return true; } foreach ($fixedFilters as $column => $value) { if (strcasecmp($column, 'channel') !== 0) continue; $requested = is_array($channel) ? array_map('strval', $channel) : [(string) $channel]; $configured = is_array($value) ? array_map('strval', $value) : [(string) $value]; sort($requested); sort($configured); return $requested === $configured; } return false; } /** @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(); } /** @param array $rows @return array */ private function productNames(array $rows): array { $skuIds = collect($rows)->map(fn (object $row): ?string => $row->sku_id ?: $row->shop_sku_id) ->filter()->unique()->values()->all(); if ($skuIds === []) return []; try { $dataset = $this->datasets->get(self::SKU_DATASET); } catch (\Throwable) { return []; } if (! $this->mirror->ready($dataset) || ! Schema::connection($this->mirror->connection()->getName())->hasTable('ops_jst_sku')) { return []; } return $this->mirror->connection()->table('ops_jst_sku') ->whereIn('sku_id', $skuIds)->pluck('name', 'sku_id') ->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name]) ->all(); } /** @param array $rows @return array */ private function importedRows(array $rows): array { if (! Schema::hasTable('ec_alipay_bills')) { return []; } $recordCodes = collect($rows)->pluck('p_auto_id')->filter()->unique()->values()->all(); if ($recordCodes === []) { return []; } return DB::table('ec_alipay_bills') ->where('company_id', (int) (company_id() ?: 1)) ->whereNull('deleted_at') ->whereIn('record_code', $recordCodes) ->get()->keyBy(fn (object $row): string => (string) $row->record_code)->all(); } /** @param array $rows */ public function mapDouyinRows(array $rows): array { $shops = $this->shopNames($rows); return array_map(fn (object $row): array => $this->mapRowFor($row, $shops[(string) $row->shop_id] ?? null, 'douyin'), $rows); } private function mapRow(object $row, ?string $shopName, array $productNames = [], array $importedRows = []): 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; $mapped = [ 'id' => (string) $row->Id, 'record_code' => (string) ($row->p_auto_id ?: $row->Id), 'store_name' => $shopName, 'custom_remark' => null, 'bill_item' => $row->fee_name, 'alipay_account' => null, 'account_order_no' => null, 'business_serial_no' => null, 'merchant_order_no' => null, 'business_base_order_no' => null, 'goods_name' => $productNames[(string) ($row->sku_id ?: $row->shop_sku_id)] ?? null, 'counterparty_account' => null, 'occurred_at' => $this->dateTime($row->fee_time), 'income' => $income, 'expense' => $expense, 'balance' => null, 'business_type' => null, 'remark' => null, 'online_order_no' => $row->so_id, 'online_real_store' => $shopName, 'business_description' => null, 'business_bill_source' => null, 'business_order_no' => null, 'created_at' => $this->dateTime($row->createTime), 'updated_at' => $this->dateTime($row->updateTime), 'modifier' => '', ]; $imported = $importedRows[$mapped['record_code']] ?? null; if ($imported === null) { return $mapped; } foreach ([ 'custom_remark', 'alipay_account', 'account_order_no', 'business_serial_no', 'merchant_order_no', 'business_base_order_no', 'counterparty_account', 'balance', 'business_type', 'remark', 'business_description', 'business_bill_source', 'business_order_no', 'modifier', ] as $field) { $mapped[$field] = $imported->{$field} ?? $mapped[$field]; } if ($mapped['goods_name'] === null || $mapped['goods_name'] === '') { $mapped['goods_name'] = $imported->goods_name ?? null; } return $mapped; } /** @return array */ private function mapRowFor(object $row, ?string $shopName, string $mapping, array $productNames = [], array $importedRows = []): array { if ($mapping === 'pinduoduo') { return $this->mapPinduoduoRow($row, $shopName); } if ($mapping === 'dewu') { return $this->mapDewuRow($row, $shopName); } if ($mapping !== 'douyin') { return $this->mapRow($row, $shopName, $productNames, $importedRows); } $amount = abs((float) ($row->amount ?? 0)); $income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0; $expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0; $direction = $this->directionName($row->inout_type); return [ 'id' => (string) $row->Id, 'record_code' => (string) $row->Id, 'store_name' => $shopName, 'finance_remark' => null, 'order_no' => $row->so_id ?: $row->p_auto_id, 'amount' => $amount, 'custom_remark' => null, 'bill_item' => $row->fee_name, 'occurred_at' => $this->dateTime($row->fee_time), 'income' => $income, 'expense' => $expense, 'balance' => round($income - $expense, 2), 'remark' => null, 'modifier' => '聚水潭', 'created_at' => $this->dateTime($row->createTime), 'updated_at' => $this->dateTime($row->updateTime), 'payload' => [ 'merchant_name' => $shopName, 'move_serial_no' => $row->auto_id ?: $row->primary_key, 'order_no_2' => $row->p_auto_id ?: $row->so_id, 'online_order_no' => $row->so_id, 'sub_order_no' => $row->shop_i_id, 'goods_id' => $row->sku_id ?: $row->shop_sku_id, 'account_type' => $direction, 'move_summary' => $row->fee_name, 'order_placed_at' => $this->dateTime($row->createTime), 'business_type' => $row->combine_type, 'settle_account' => $row->channel, 'move_scene' => '头条放心购', 'talent_id' => null, 'talent_name' => null, ], ]; } /** @return array */ private function mapPinduoduoRow(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->auto_id ?: $row->Id), 'store_name' => $shopName, 'finance_remark' => null, 'order_no' => $row->so_id ?: $row->p_auto_id, 'amount' => $amount, 'custom_remark' => null, 'bill_item' => $row->fee_name, 'occurred_at' => $this->dateTime($row->fee_time), 'income' => $income, 'expense' => $expense, 'balance' => round($income - $expense, 2), 'remark' => null, 'updated_at' => $this->dateTime($row->updateTime), 'modifier' => 'RPA', 'payload' => [ 'merchant_order_no' => $row->so_id ?: $row->p_auto_id, 'online_order_no' => $row->so_id, 'finance_type' => $row->fee_name, 'type' => '拼多多货款账户', ], ]; } /** @return array */ private function mapDewuRow(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; $orderNo = $row->so_id ?: $row->p_auto_id; $goodsNo = $row->sku_id ?: $row->shop_sku_id ?: $row->shop_i_id; return [ 'id' => (string) $row->Id, 'record_code' => (string) ($row->auto_id ?: $row->Id), 'store_name' => $shopName, 'finance_remark' => null, 'order_no' => $orderNo, 'amount' => $amount, 'custom_remark' => null, 'bill_item' => $row->fee_name, 'occurred_at' => $this->dateTime($row->fee_time), 'income' => $income, 'expense' => $expense, 'balance' => round($income - $expense, 2), 'remark' => null, 'updated_at' => $this->dateTime($row->updateTime), 'modifier' => '聚水潭', 'payload' => [ 'online_order_no' => $orderNo, 'store_2' => $shopName, 'order_type' => null, 'goods_name' => null, 'goods_no' => $goodsNo, 'quantity' => null, 'channel' => match ((string) $row->channel) { 'Dewu' => '得物', 'DewuTK' => '退货退款订单', 'DewuKJ' => '扣减其他费用明细', default => (string) $row->channel, }, 'source_channel' => $row->channel, 'business_type' => $this->directionName($row->inout_type), 'online_real_store' => $shopName, 'order_created_at' => null, 'ship_return_at' => null, 'settle_status' => null, 'pay_period' => null, 'pulled_at' => $this->dateTime($row->createTime), ], ]; } 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)); } /** @return Collection */ private function boundedGet(Builder $query): Collection { $connection = $query->getConnection(); if ($connection->getDriverName() !== 'mysql') { return $query->get(); } return collect($connection->select( 'SET STATEMENT max_statement_time=' . self::PAGE_QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(), $query->getBindings(), )); } private function boundedCount(Builder $query): int { $count = $query->getConnection()->query() ->fromSub($query, 'bounded_count') ->selectRaw('COUNT(*) AS aggregate'); return (int) ($this->boundedGet($count)->first()->aggregate ?? 0); } /** @param array $filters */ private function aggregateCacheKey( string $datasetKey, array $filters, string|array|null $channel, bool $excludeNonTaobaoPlatforms, ): string { $normalizedFilters = array_filter( array_map( static fn (mixed $value): string => trim((string) $value), $filters, ), static fn (string $value): bool => $value !== '', ); ksort($normalizedFilters); $normalizedChannel = is_array($channel) ? array_map('strval', $channel) : $channel; if (is_array($normalizedChannel)) { sort($normalizedChannel); } return 'store-receiving-bills:aggregate:v3:' . hash('sha256', (string) json_encode([ 'dataset' => $datasetKey, 'filters' => $normalizedFilters, 'channel' => $normalizedChannel, 'exclude_non_taobao' => $excludeNonTaobaoPlatforms, ], JSON_UNESCAPED_UNICODE)); } /** @param callable():array{income:float,expense:float,count:?int} $resolver */ private function rememberAggregateSummary(string $key, callable $resolver): array { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } try { return Cache::lock($key . ':lock', self::AGGREGATE_CACHE_LOCK_SECONDS) ->block(self::AGGREGATE_CACHE_WAIT_SECONDS, function () use ($key, $resolver): array { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } $resolved = $resolver(); Cache::put($key, $resolved, self::AGGREGATE_CACHE_SECONDS); return $resolved; }); } catch (LockTimeoutException $exception) { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } throw new RuntimeException('store_receiving_bill_aggregate_busy', 0, $exception); } } private function filterOptionsCacheKey( string $datasetKey, string|array $channel, bool $excludeNonTaobaoPlatforms, ): string { $normalizedChannel = is_array($channel) ? array_map('strval', $channel) : $channel; if (is_array($normalizedChannel)) { sort($normalizedChannel); } return 'store-receiving-bills:filter-options:v1:' . hash('sha256', (string) json_encode([ 'dataset' => $datasetKey, 'channel' => $normalizedChannel, 'exclude_non_taobao' => $excludeNonTaobaoPlatforms, ], JSON_UNESCAPED_UNICODE)); } /** @param callable():array{shops:array,bill_items:array} $resolver */ private function rememberFilterOptions(string $key, callable $resolver): array { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } try { return Cache::lock($key . ':lock', self::AGGREGATE_CACHE_LOCK_SECONDS) ->block(self::AGGREGATE_CACHE_WAIT_SECONDS, function () use ($key, $resolver): array { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } $resolved = $resolver(); Cache::put($key, $resolved, self::FILTER_OPTIONS_CACHE_SECONDS); return $resolved; }); } catch (LockTimeoutException $exception) { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } throw new RuntimeException('store_receiving_bill_filter_options_busy', 0, $exception); } } private function escapeLike(string $value): string { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); } }