[ ['woTypeId', 'woNo', 'Id'], ['woTypeId', 'completeTime', 'woNo', 'Id'], ], self::PAYMENT_MAP_DATASET => [['woTypeId', 'woNo']], ]; private const WORK_ORDER_STATUS_LABELS = [ 'WAIT_AUDIT' => '待审核', 'REJECT_AUDIT' => '审核驳回', 'WAIT_CONFIRM' => '待确认', 'CONFIRMING' => '确认中', 'CONFIRMED' => '已完成', 'DISABLED' => '已作废', ]; private const COMPLETED_WORK_ORDER_STATUSES = ['CONFIRMED', '已完成', '已确认']; /** 打款金额在 businessFieldList 中的候选字段名(按优先级) */ private const PAYMENT_AMOUNT_FIELDS = ['打款金额', '付款金额', '小额打款金额', 'amountField']; private readonly ConnectionInterface $connection; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, private readonly GenericBusinessMirrorRepository $mirror, ) { $this->connection = $mirror->connection(); } public function available(): bool { $schema = Schema::connection($this->connection->getName()); try { return $schema->hasTable('business_mirror_table_states') && $schema->hasTable($this->dataset(self::WORK_ORDER_DATASET)->targetTable()) && $schema->hasTable($this->dataset(self::PAYMENT_MAP_DATASET)->targetTable()); } catch (Throwable) { return false; } } public function ready(): bool { return $this->mirror->ready($this->dataset(self::WORK_ORDER_DATASET)) && $this->mirror->ready($this->dataset(self::PAYMENT_MAP_DATASET)); } public function queryReady(): bool { $schema = Schema::connection($this->connection->getName()); try { foreach (self::REQUIRED_QUERY_INDEXES as $datasetKey => $requiredIndexes) { $table = $this->dataset($datasetKey)->targetTable(); if (! $schema->hasTable($table)) { return false; } $indexes = collect($schema->getIndexes($table)) ->map(static fn (array $index): array => array_map( 'strtolower', (array) ($index['columns'] ?? []), )); foreach ($requiredIndexes as $required) { if (! $indexes->contains(array_map('strtolower', $required))) { return false; } } } } catch (Throwable) { return false; } return true; } /** @return array|null> */ public function states(): array { return [ self::WORK_ORDER_DATASET => $this->mirror->state($this->dataset(self::WORK_ORDER_DATASET)), self::PAYMENT_MAP_DATASET => $this->mirror->state($this->dataset(self::PAYMENT_MAP_DATASET)), ]; } /** * @param array|null>|null $states * @return array{code:string,last_succeeded_at:?string} */ public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array { $states ??= $this->states(); if (count(array_filter($states, 'is_array')) !== 2) { return ['code' => 'unavailable', 'last_succeeded_at' => null]; } $initialized = collect($states)->every( static fn (?array $state): bool => ($state['initial_completed_at'] ?? null) !== null ); $statuses = collect($states)->pluck('status')->map('strval'); $lastSucceededAt = collect($states) ->pluck('last_succeeded_at') ->filter() ->sort() ->first(); if (! $initialized) { $code = $statuses->contains('failed') ? 'initial_failed' : 'initializing'; } elseif ($statuses->contains('failed')) { $code = 'failed'; } elseif ($statuses->contains('running')) { $code = 'syncing'; } else { $lastSucceeded = $lastSucceededAt ? Carbon::parse($lastSucceededAt) : null; $lagMinutes = $lastSucceeded === null ? null : (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded->getTimestamp()) / 60); $code = $lagMinutes === null || $lagMinutes > 30 ? 'stale' : 'healthy'; } return ['code' => $code, 'last_succeeded_at' => $lastSucceededAt]; } /** * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters * @return array{data:list>,total:int,page_payment_amount:string,filtered_payment_amount:string} */ public function paginate(array $filters, int $page, int $perPage): array { $this->ensureReady(); $page = max($page, 1); $perPage = min(max($perPage, 1), 100); $query = $this->baseQuery(); $this->applyFilters($query, $filters); $summary = $this->filteredSummary($filters); $rows = $this->boundedGet($query ->select($this->selectColumns()) ->orderByDesc('w.completeTime') ->orderByDesc('p.woNo') ->forPage($page, $perPage)) ->map(fn (object $row): array => $this->decorate((array) $row)) ->all(); return [ 'data' => $rows, 'total' => $summary['count'], 'page_payment_amount' => $this->sumMoney(array_column($rows, 'payment_amount')), 'filtered_payment_amount' => $summary['payment_amount'], ]; } /** * 统计当前筛选条件下全部记录的打款金额合计。 * * 只取金额所需字段,按 Id 做键集分块批量读取:不使用逐行游标(本地/远端库时每次取行都是一次网络往返), * 也不复用导出用的分页排序查询(会为大结果集重复执行连接与排序)。 * * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters */ public function filteredPaymentAmount(array $filters, int $chunkSize = 1000): string { return $this->filteredSummary($filters, $chunkSize)['payment_amount']; } /** * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters * @return array{count:int,payment_amount:string} */ private function filteredSummary(array $filters, int $chunkSize = 1000): array { $this->ensureReady(); $key = $this->summaryCacheKey($filters); return $this->rememberArray( $key, self::SUMMARY_CACHE_SECONDS, fn (): array => $this->calculateFilteredSummary($filters, $chunkSize), 'small_payment_work_order_summary_busy', ); } /** * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters * @return array{count:int,payment_amount:string} */ private function calculateFilteredSummary(array $filters, int $chunkSize): array { $this->ensureReady(); $query = $this->baseQuery(); $this->applyFilters($query, $filters); $chunkSize = min(max($chunkSize, 100), 5000); $cents = 0; $count = 0; $lastId = null; do { $chunk = $this->boundedGet((clone $query) ->select(['w.Id AS source_id', 'w.businessFieldList']) ->when($lastId !== null, static fn (Builder $q): Builder => $q->where('w.Id', '>', $lastId)) ->orderBy('w.Id') ->limit($chunkSize)); foreach ($chunk as $row) { $cents += $this->centsFromMoney($this->paymentAmountOf($row->businessFieldList ?? null)); } $count += $chunk->count(); $lastId = $chunk->isEmpty() ? null : $chunk->last()->source_id; } while ($chunk->count() === $chunkSize); return ['count' => $count, 'payment_amount' => $this->formatCents($cents)]; } private function paymentAmountOf(mixed $businessFieldList): ?string { return $this->money($this->field( $this->businessFields($businessFieldList), self::PAYMENT_AMOUNT_FIELDS, )); } /** * @param array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string} $filters * @return iterable> */ public function exportRows(array $filters, int $chunkSize = 100): iterable { $this->ensureReady(); $chunkSize = min(max($chunkSize, 1), 100); $query = $this->baseQuery(); $this->applyFilters($query, $filters); $page = 1; do { $rows = (clone $query) ->select($this->selectColumns()) ->orderByDesc('w.completeTime') ->orderByDesc('p.woNo') ->forPage($page, $chunkSize) ->get(); foreach ($rows as $row) { yield $this->decorate((array) $row); } $page++; } while ($rows->count() === $chunkSize); } /** * 返回当前小额打款镜像中出现过的店铺名称列表,用于筛选下拉/自动补全。 * * @return list */ public function shopNames(): array { return $this->filterOptions()['shops']; } /** * 返回当前小额打款镜像中出现过的打款原因列表,用于筛选下拉/自动补全。 * * @return list */ public function paymentReasons(): array { return $this->filterOptions()['payment_reasons']; } /** @return array{shops:list,payment_reasons:list} */ public function filterOptions(): array { if (! $this->available() || ! $this->ready() || ! $this->queryReady()) { return ['shops' => [], 'payment_reasons' => []]; } $table = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable(); try { return $this->rememberArray( 'small-payment-work-orders:filter-options:v1', self::FILTER_OPTIONS_CACHE_SECONDS, function () use ($table): array { $shops = $this->boundedGet($this->connection->table($table) ->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID) ->whereNotNull('shop_name') ->where('shop_name', '<>', '') ->distinct() ->select('shop_name') ->orderBy('shop_name') ->limit(1000)) ->pluck('shop_name') ->map(static fn (mixed $name): string => (string) $name) ->all(); $reasons = $this->boundedGet($this->connection->table($table) ->where('woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID) ->whereNotNull('payment_reason') ->where('payment_reason', '<>', '') ->distinct() ->select('payment_reason') ->orderBy('payment_reason') ->limit(200)) ->pluck('payment_reason') ->map(static fn (mixed $reason): string => (string) $reason) ->all(); return ['shops' => $shops, 'payment_reasons' => $reasons]; }, 'small_payment_work_order_filter_options_busy', ); } catch (Throwable) { return ['shops' => [], 'payment_reasons' => []]; } } private function ensureReady(): void { if (! $this->ready() || ! $this->queryReady()) { throw new RuntimeException('business_mirror_dataset_not_ready'); } } private function baseQuery(): Builder { $workOrders = $this->dataset(self::WORK_ORDER_DATASET)->targetTable(); $paymentMap = $this->dataset(self::PAYMENT_MAP_DATASET)->targetTable(); return $this->connection->table($workOrders . ' AS w') ->join($paymentMap . ' AS p', static function ($join): void { $join->on('p.woTypeId', '=', 'w.woTypeId') ->on('p.woNo', '=', 'w.woNo'); }) ->leftJoin($workOrders . ' AS newer_work_order', static function ($join): void { $join->on('newer_work_order.woTypeId', '=', 'w.woTypeId') ->on('newer_work_order.woNo', '=', 'w.woNo') ->on('newer_work_order.Id', '>', 'w.Id'); }) ->where('w.woTypeId', self::SMALL_PAYMENT_WORK_TYPE_ID) ->whereNull('newer_work_order.Id'); } /** @return list */ private function selectColumns(): array { return [ 'w.Id AS source_id', 'p.woNo AS work_order_no', 'p.transaction_no AS mapped_online_order_no', 'w.status AS work_order_status', 'p.status AS mapped_status', 'w.title AS work_order_title', 'p.title AS mapped_title', 'w.content AS work_order_content', 'w.grade AS priority', 'w.urging AS urging_count', 'w.createby AS creator', 'w.executors', 'w.completer', 'w.completeTime AS completed_at', 'w.created AS work_order_created_at', 'w.updated AS work_order_updated_at', 'p.shop_name', 'p.payment_reason', 'p.parse_status', 'p.parse_error', 'p.work_order_create_time', 'p.work_order_update_time', 'w.businessFieldList AS business_field_list', ]; } /** @param array $filters */ private function applyFilters(Builder $query, array $filters): void { $from = $this->filterDateTime($filters['from'], false); $to = $this->filterDateTime($filters['to'], true); $query->where('w.completeTime', '>=', $from) ->where('w.completeTime', '<', $to); if ($filters['work_order_no'] !== '') { $query->whereRaw("p.woNo LIKE ? ESCAPE '!'", [$this->contains($filters['work_order_no'])]); } if ($filters['shop_name'] !== '') { $query->whereRaw("p.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]); } if ($filters['transaction_no'] !== '') { $query->whereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['transaction_no'])]); } if ($filters['online_order_no'] !== '') { $query->where(function (Builder $onlineOrder) use ($filters): void { $needle = $this->contains($filters['online_order_no']); $onlineOrder->whereRaw("p.transaction_no LIKE ? ESCAPE '!'", [$needle]) ->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$needle]); }); } if ($filters['status'] !== '') { $query->where(function (Builder $status) use ($filters): void { $statuses = $this->workOrderStatusesForFilter($filters['status']); $status->whereIn('w.status', $statuses) ->orWhereIn('p.status', $statuses) ->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($filters['status'])]); $paymentMethod = $this->paymentMethodForStatus($filters['status']); if ($paymentMethod !== null || trim($filters['status']) === '已打款') { $status->orWhere(function (Builder $payment) use ($paymentMethod): void { $payment->whereIn('w.status', self::COMPLETED_WORK_ORDER_STATUSES); if ($paymentMethod !== null) { $this->applyPaymentMethodFilter($payment, $paymentMethod); } }); } }); } if ($filters['payment_reason'] !== '') { $query->whereRaw("p.payment_reason LIKE ? ESCAPE '!'", [$this->contains($filters['payment_reason'])]); } } /** @param array $row @return array */ private function decorate(array $row): array { $fields = $this->businessFields($row['business_field_list'] ?? null); $sourceWorkOrderStatus = $this->firstFilled( $row['work_order_status'] ?? null, $row['mapped_status'] ?? null, ); $row['work_order_status'] = $this->workOrderStatusLabel($sourceWorkOrderStatus); $row['work_order_title'] = $this->firstFilled($row['work_order_title'] ?? null, $row['mapped_title'] ?? null); $row['work_order_type'] = $this->field($fields, ['工单类型']) ?: '小额打款'; $paymentMethod = $this->field($fields, [ '打款方式', '收款方式', '支付方式', 'SELECTField1413856620250724113227', ]); $row['payment_status'] = $this->firstFilled( $this->field($fields, ['打款状态', '支付打款状态', '付款状态']), $this->completedPaymentStatus($sourceWorkOrderStatus, $paymentMethod), '未提供', ); $row['alipay_batch_no'] = $this->field($fields, [ '支付宝批次号', '支付宝打款批次号', '打款批次号', '批次号', 'alipayBatchNo', 'batchNo', ]); $row['payment_amount'] = $this->money($this->field($fields, self::PAYMENT_AMOUNT_FIELDS)); $row['online_order_no'] = $this->firstFilled( $this->field($fields, ['线上订单号', '线上单号', '订单号', 'orderField']), $row['mapped_online_order_no'] ?? null, ); $row['buyer_account'] = $this->field($fields, [ '买家账号', '买家帐号', '买家', 'buyerIdField', ]); $row['alipay_account'] = $this->field($fields, [ '支付宝账号', '支付宝帐号', 'alipayAccountField', ]); $row['alipay_name'] = $this->field($fields, [ '支付宝姓名', '支付宝实名', '支付宝账户姓名', 'alipayNameField', ]); $row['product_code'] = $this->field($fields, [ '商品编码', '商品ID', '商品编号', 'iid', ]); $row['sku_code'] = $this->field($fields, [ 'SKU编码', 'SKU', '规格编码', 'skuId', ]); $row['platform'] = $this->field($fields, ['平台', '平台名称', 'platform']); $row['actual_paid_amount'] = $this->money($this->field($fields, [ '实际已付金额', '实付金额', '买家实付金额', 'actualPaidAmountField', ])); $row['payment_time'] = $this->field($fields, [ '支付时间', '打款时间', '付款时间', 'payDateField', ]); unset( $row['business_field_list'], $row['mapped_online_order_no'], $row['mapped_status'], $row['mapped_title'], ); return $row; } /** @return array */ private function businessFields(mixed $json): array { if (! is_string($json) || trim($json) === '') { return []; } try { $decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR); } catch (Throwable) { return []; } $fields = []; $this->flattenFields($decoded, $fields); return $fields; } /** @param array $fields */ private function flattenFields(mixed $node, array &$fields): void { if (! is_array($node)) { return; } $label = $this->arrayValue($node, [ 'fieldName', 'field_name', 'label', 'name', 'title', 'fieldId', 'field_id', ]); $value = $this->arrayValue($node, [ 'fieldValue', 'field_value', 'valueLabel', 'value_label', 'value', 'text', ]); if ($label !== '' && $value !== '') { $fields[$this->normalizeLabel($label)] = $value; } foreach ($node as $key => $child) { if (is_string($key) && ! is_array($child) && ! is_object($child)) { $text = trim((string) $child); if ($text !== '') { $fields[$this->normalizeLabel($key)] ??= $text; } } if (is_array($child)) { $this->flattenFields($child, $fields); } } } /** @param array $row @param list $keys */ private function arrayValue(array $row, array $keys): string { foreach ($keys as $key) { if (array_key_exists($key, $row) && is_scalar($row[$key])) { $value = trim((string) $row[$key]); if ($value !== '') { return $value; } } } return ''; } /** @param array $fields @param list $labels */ private function field(array $fields, array $labels): string { foreach ($labels as $label) { $value = $fields[$this->normalizeLabel($label)] ?? ''; if ($value !== '') { return $value; } } return ''; } private function normalizeLabel(string $label): string { return mb_strtolower((string) preg_replace('/[\s::()()_\-]+/u', '', trim($label))); } private function money(string $value): ?string { $normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value)); if (preg_match('/^(-?)(\d+)(?:\.(\d{1,6}))?$/', $normalized, $matches) !== 1) { return null; } $fraction = str_pad($matches[3] ?? '', 3, '0'); $cents = ((int) $matches[2] * 100) + (int) substr($fraction, 0, 2); if ((int) $fraction[2] >= 5) { $cents++; } $sign = $matches[1] === '-' && $cents !== 0 ? '-' : ''; return $sign . intdiv($cents, 100) . '.' . str_pad((string) ($cents % 100), 2, '0', STR_PAD_LEFT); } /** @param list $amounts */ private function sumMoney(array $amounts): string { $cents = 0; foreach ($amounts as $amount) { $cents += $this->centsFromMoney($amount); } return $this->formatCents($cents); } /** 将 '123.45' / '-123.45' 形式的金额字符串转成分(无效值计 0)。 */ private function centsFromMoney(mixed $amount): int { if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $matches)) { return 0; } $value = ((int) $matches[2] * 100) + (int) $matches[3]; return $matches[1] === '-' ? -$value : $value; } private function formatCents(int $cents): string { $sign = $cents < 0 ? '-' : ''; $absolute = abs($cents); return $sign . intdiv($absolute, 100) . '.' . str_pad((string) ($absolute % 100), 2, '0', STR_PAD_LEFT); } private function firstFilled(mixed ...$values): string { foreach ($values as $value) { $text = trim((string) $value); if ($text !== '') { return $text; } } return ''; } private function workOrderStatusLabel(string $status): string { $code = strtoupper(trim($status)); return self::WORK_ORDER_STATUS_LABELS[$code] ?? $status; } private function completedPaymentStatus(string $workOrderStatus, string $paymentMethod): string { $normalizedStatus = strtoupper(trim($workOrderStatus)); if (! in_array($normalizedStatus, self::COMPLETED_WORK_ORDER_STATUSES, true)) { return ''; } if (str_contains($paymentMethod, '支付宝')) { return '支付宝已打款'; } if (str_contains($paymentMethod, '银行卡')) { return '银行卡已打款'; } return '已打款'; } private function paymentMethodForStatus(string $status): ?string { return match (trim($status)) { '支付宝已打款' => '支付宝账号', '银行卡已打款' => '银行卡', default => null, }; } private function applyPaymentMethodFilter(Builder $query, string $paymentMethod): void { $query->where(function (Builder $fields) use ($paymentMethod): void { foreach (['value', 'fieldValue', 'valueLabel'] as $key) { $fields->orWhereRaw( "w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains(sprintf('"%s":"%s"', $key, $paymentMethod))], ); } }); } /** @return list */ private function workOrderStatusesForFilter(string $status): array { $status = trim($status); $code = array_search($status, self::WORK_ORDER_STATUS_LABELS, true); return array_values(array_unique(array_filter([ $status, is_string($code) ? $code : null, ], static fn (?string $value): bool => $value !== null && $value !== ''))); } /** @param array $filters */ private function summaryCacheKey(array $filters): string { $normalized = array_map(static fn (mixed $value): string => trim((string) $value), $filters); ksort($normalized); return 'small-payment-work-orders:summary:v1:' . hash( 'sha256', (string) json_encode($normalized, JSON_UNESCAPED_UNICODE), ); } /** @param callable():array $resolver @return array */ private function rememberArray(string $key, int $seconds, callable $resolver, string $busyMessage): array { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } try { return Cache::lock($key . ':lock', self::CACHE_LOCK_SECONDS) ->block(self::CACHE_WAIT_SECONDS, function () use ($key, $seconds, $resolver): array { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } $resolved = $resolver(); Cache::put($key, $resolved, $seconds); return $resolved; }); } catch (LockTimeoutException $exception) { $cached = Cache::get($key); if (is_array($cached)) { return $cached; } throw new RuntimeException($busyMessage, 0, $exception); } } /** @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::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(), $query->getBindings(), )); } /** * 将前端传来的日期或日期时间字符串统一为 SQL 比较用的日期时间。 * 仅日期时:from 取 00:00:00,to 取次日 00:00:00(半开区间)。 * 含分钟时:to 取下一分钟;含秒时:to 取下一秒,完整包含用户选择的最小时间单位。 */ private function filterDateTime(string $value, bool $isUpperBound): string { $value = trim($value); $hasTime = str_contains($value, ':') || str_contains($value, 'T'); if (! $hasTime) { return $isUpperBound ? Carbon::parse($value)->addDay()->format('Y-m-d 00:00:00') : $value . ' 00:00:00'; } $carbon = Carbon::parse($value); if (! $isUpperBound) { return $carbon->format('Y-m-d H:i:s'); } return preg_match('/:\d{2}:\d{2}$/', str_replace('T', ' ', $value)) === 1 ? $carbon->clone()->addSecond()->format('Y-m-d H:i:s') : $carbon->clone()->addMinute()->format('Y-m-d H:i:s'); } private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; } private function dataset(string $key): BusinessMirrorDataset { return $this->datasets->get($key); } }