connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror') ); } /** * @return array{status:string,rows:int,invalid:int,placeholders:int,duplicates:int,scope:string,dataset_key:string} */ public function import( string $path, string $scope = self::DEFAULT_SCOPE, bool $replace = false, ): array { $scope = trim($scope) !== '' ? trim($scope) : self::DEFAULT_SCOPE; if (! is_file($path)) { throw new RuntimeException('流程明细 CSV 文件不存在:' . $path); } [$rows, $invalid, $placeholders, $duplicates] = $this->readCsv($path, $scope); if ($rows === []) { throw new RuntimeException('流程明细 CSV 没有可导入的数据。'); } $now = now(); $datasetKey = self::DATASET_PREFIX . $scope; $generation = (string) Str::uuid(); $upserted = 0; $this->connection->transaction(function () use ( $rows, $replace, $scope, $now, $generation, &$upserted, ): void { if ($replace) { $this->connection->table('ding_process_detail_mirror') ->where('source_scope', $scope) ->delete(); } foreach (array_chunk($rows, 500) as $chunk) { $this->connection->table('ding_process_detail_mirror')->upsert( $chunk, ['business_id', 'line_no'], [ 'business_month', 'originator_name', 'status', 'document_type', 'purpose', 'account_code', 'account_name', 'amount', 'bank_account_code', 'bank_account_name', 'bank_amount', 'payment_method', 'paid_at', 'source_scope', 'synced_at', 'updated_at', ], ); $upserted += count($chunk); } }); $rowCount = (int) $this->connection->table('ding_process_detail_mirror') ->where('source_scope', $scope) ->count(); $values = [ 'source_key' => 'dingtalk_oa', 'source_object' => $scope === self::DEFAULT_SCOPE ? 'oa approval detail (personal permission scope)' : 'oa approval detail (' . $scope . ')', 'target_table' => 'ding_process_detail_mirror', 'status' => 'succeeded', 'run_mode' => 'full', 'generation' => $generation, 'cursor' => json_encode([ 'source' => 'legacy_csv', 'mirror_details' => $rowCount, ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'high_watermark' => null, 'window_started_at' => null, 'window_ends_at' => null, 'initial_completed_at' => $now, 'last_full_succeeded_at' => $now, 'last_attempted_at' => $now, 'last_succeeded_at' => $now, 'last_upserted_rows' => $upserted, 'mirror_row_count' => $rowCount, 'last_error_code' => null, 'updated_at' => $now, ]; $this->connection->table('business_mirror_table_states')->updateOrInsert( ['dataset_key' => $datasetKey], $values + ['created_at' => $now], ); return [ 'status' => 'succeeded', 'rows' => $upserted, 'invalid' => $invalid, 'placeholders' => $placeholders, 'duplicates' => $duplicates, 'scope' => $scope, 'dataset_key' => $datasetKey, ]; } /** * @return array{0:list>,1:int,2:int,3:int} */ private function readCsv(string $path, string $scope): array { $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException('流程明细 CSV 无法打开:' . $path); } try { $header = fgetcsv($handle); if ($header === false) { throw new RuntimeException('流程明细 CSV 缺少表头。'); } $header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]); $header = array_map(static fn (mixed $value): string => trim((string) $value), $header); $missing = array_values(array_diff(self::HEADERS, $header)); if ($missing !== []) { throw new RuntimeException('流程明细 CSV 缺少列:' . implode('、', $missing)); } $index = array_flip($header); $rows = []; $qualities = []; $invalid = 0; $placeholders = 0; $duplicates = 0; $now = now(); while (($data = fgetcsv($handle)) !== false) { if ($data === [null] || count(array_filter( $data, static fn (mixed $value): bool => trim((string) $value) !== '', )) === 0) { continue; } $businessId = trim((string) ($data[$index['审批编号']] ?? '')); $lineNo = trim((string) ($data[$index['单据内行号']] ?? '')); if ($businessId === '' || preg_match('/^\d+$/', $lineNo) !== 1) { $invalid++; continue; } $accountCode = trim((string) ($data[$index['科目编码']] ?? '')); $accountName = trim((string) ($data[$index['科目名称']] ?? '')); $amountRaw = trim((string) ($data[$index['金额(元)']] ?? '')); // 历史导入遗留的占位行:用途/科目/银行字段全部是 251,金额为空。 if ($accountCode === '251' && $accountName === '251' && $amountRaw === '') { $placeholders++; continue; } $key = $businessId . '|' . (int) $lineNo; $candidate = [ 'business_id' => mb_substr($businessId, 0, 32), 'line_no' => (int) $lineNo, 'business_month' => preg_match('/^\d{6}/', $businessId) === 1 ? substr($businessId, 0, 6) : '', 'originator_name' => mb_substr(trim((string) ($data[$index['发起人']] ?? '')), 0, 100), 'status' => mb_substr(trim((string) ($data[$index['状态']] ?? '')), 0, 20), 'document_type' => mb_substr(trim((string) ($data[$index['单据类型']] ?? '')), 0, 100), 'purpose' => trim((string) ($data[$index['用途/摘要']] ?? '')), 'account_code' => mb_substr($accountCode, 0, 64), 'account_name' => mb_substr($accountName, 0, 255), 'amount' => $this->decimal($data[$index['金额(元)']] ?? null), 'bank_account_code' => mb_substr(trim((string) ($data[$index['银行科目编码']] ?? '')), 0, 255), 'bank_account_name' => mb_substr(trim((string) ($data[$index['银行科目名称']] ?? '')), 0, 255), 'bank_amount' => $this->decimal($data[$index['银行付款金额']] ?? null), 'payment_method' => mb_substr(trim((string) ($data[$index['付款方式']] ?? '')), 0, 50), 'paid_at' => mb_substr(trim((string) ($data[$index['打款时间']] ?? '')), 0, 50), 'source_scope' => $scope, 'synced_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; $quality = $this->quality($candidate); if (isset($qualities[$key])) { $duplicates++; if ($quality < $qualities[$key]) { continue; } } $rows[$key] = $candidate; $qualities[$key] = $quality; } return [array_values($rows), $invalid, $placeholders, $duplicates]; } finally { fclose($handle); } } private function decimal(mixed $value): ?string { $value = trim((string) $value); if ($value === '' || ! is_numeric($value)) { return null; } return number_format((float) $value, 2, '.', ''); } /** @param array $row */ private function quality(array $row): int { $score = 0; $score += trim((string) ($row['purpose'] ?? '')) !== '' && trim((string) ($row['purpose'] ?? '')) !== '251' ? 1 : 0; $score += trim((string) ($row['account_name'] ?? '')) !== '' && trim((string) ($row['account_name'] ?? '')) !== '251' ? 1 : 0; $score += is_numeric($row['amount'] ?? null) && (float) $row['amount'] !== 0.0 ? 4 : 0; $score += trim((string) ($row['bank_account_code'] ?? '')) !== '' && trim((string) ($row['bank_account_code'] ?? '')) !== '251' ? 2 : 0; $score += is_numeric($row['bank_amount'] ?? null) && (float) $row['bank_amount'] !== 0.0 ? 2 : 0; $score += trim((string) ($row['paid_at'] ?? '')) !== '' && trim((string) ($row['paid_at'] ?? '')) !== '251' ? 1 : 0; return $score; } }