From a4ce008bca7d4c7e199bdd4a4b6f1f4c10f7ae0f Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Mon, 26 Oct 2026 16:17:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=A2=E5=8A=A1=E7=BB=8F=E8=90=A5?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8=E6=96=B0=E5=A2=9E=E6=B5=81=E7=A8=8B=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportDingProcessDetails.php | 43 ++ app/Utilities/BusinessNavigation.php | 23 + .../business_navigation_page_extensions.php | 19 + ...eate_ding_process_detail_mirror_tables.php | 68 +++ .../Http/Controllers/ProcessDetails.php | 139 ++++++ .../DingProcessDetailMirrorRepository.php | 222 +++++++++ modules/EcommerceFinance/Routes/admin.php | 18 + .../DingProcessDetailMirrorImporter.php | 282 +++++++++++ .../process-details.blade.php | 452 ++++++++++++++++++ .../Business/ProcessDetailsPageTest.php | 133 ++++++ .../Console/ImportDingProcessDetailsTest.php | 271 +++++++++++ .../Fixtures/ding_process_details_sample.csv | 5 + .../DingProcessDetailMirrorRepositoryTest.php | 233 +++++++++ 13 files changed, 1908 insertions(+) create mode 100644 app/Console/Commands/ImportDingProcessDetails.php create mode 100644 config/business_navigation_page_extensions.php create mode 100644 database/migrations/2026_10_26_150000_create_ding_process_detail_mirror_tables.php create mode 100644 modules/EcommerceFinance/Http/Controllers/ProcessDetails.php create mode 100644 modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php create mode 100644 modules/EcommerceFinance/Services/DingProcessDetailMirrorImporter.php create mode 100644 resources/views/business/pages/financial-operations/process-details.blade.php create mode 100644 tests/Feature/Business/ProcessDetailsPageTest.php create mode 100644 tests/Feature/Console/ImportDingProcessDetailsTest.php create mode 100644 tests/Fixtures/ding_process_details_sample.csv create mode 100644 tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php diff --git a/app/Console/Commands/ImportDingProcessDetails.php b/app/Console/Commands/ImportDingProcessDetails.php new file mode 100644 index 00000000..c9e4639c --- /dev/null +++ b/app/Console/Commands/ImportDingProcessDetails.php @@ -0,0 +1,43 @@ +import( + (string) $this->argument('file'), + (string) $this->option('scope'), + (bool) $this->option('replace'), + ); + } catch (Throwable $exception) { + $this->error('导入失败:' . $exception->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf( + '导入完成:%d 行,跳过占位 %d 行,跳过无效 %d 行,重复 %d 行,范围 %s。', + $result['rows'], + $result['placeholders'], + $result['invalid'], + $result['duplicates'], + $result['scope'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Utilities/BusinessNavigation.php b/app/Utilities/BusinessNavigation.php index 6aca5455..556b645b 100644 --- a/app/Utilities/BusinessNavigation.php +++ b/app/Utilities/BusinessNavigation.php @@ -10,6 +10,29 @@ class BusinessNavigation public static function all(): array { $navigation = config('business_navigation', []); + $pageExtensions = (array) config('business_navigation_page_extensions', []); + + foreach ($navigation as &$section) { + $sectionSlug = (string) ($section['slug'] ?? ''); + + foreach ((array) ($pageExtensions[$sectionSlug] ?? []) as $extension) { + $extensionSlug = trim((string) ($extension['slug'] ?? '')); + + if ($extensionSlug === '') { + continue; + } + + $exists = collect($section['pages'] ?? [])->contains( + static fn (array $page): bool => (string) ($page['slug'] ?? '') === $extensionSlug + ); + + if (! $exists) { + $section['pages'][] = $extension; + } + } + } + unset($section); + $titles = (array) setting('navigation.titles', []); $sectionTitles = (array) ($titles['sections'] ?? []); $pageTitles = (array) ($titles['pages'] ?? []); diff --git a/config/business_navigation_page_extensions.php b/config/business_navigation_page_extensions.php new file mode 100644 index 00000000..981e6a08 --- /dev/null +++ b/config/business_navigation_page_extensions.php @@ -0,0 +1,19 @@ + [ + [ + 'slug' => 'process-details', + 'title' => '流程明细', + ], + ], +]; diff --git a/database/migrations/2026_10_26_150000_create_ding_process_detail_mirror_tables.php b/database/migrations/2026_10_26_150000_create_ding_process_detail_mirror_tables.php new file mode 100644 index 00000000..f239f079 --- /dev/null +++ b/database/migrations/2026_10_26_150000_create_ding_process_detail_mirror_tables.php @@ -0,0 +1,68 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if ($schema->hasTable('ding_process_detail_mirror')) { + return; + } + + $schema->create('ding_process_detail_mirror', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('business_id', 32); + $table->unsignedInteger('line_no'); + $table->string('business_month', 6)->index(); + $table->string('originator_name', 100)->nullable()->index(); + $table->string('status', 20)->nullable()->index(); + $table->string('document_type', 100)->nullable()->index(); + $table->text('purpose')->nullable(); + $table->string('account_code', 64)->nullable(); + $table->string('account_name', 255)->nullable(); + $table->decimal('amount', 18, 2)->nullable(); + $table->string('bank_account_code', 255)->nullable(); + $table->string('bank_account_name', 255)->nullable(); + $table->decimal('bank_amount', 18, 2)->nullable(); + $table->string('payment_method', 50)->nullable(); + $table->string('paid_at', 50)->nullable(); + $table->string('source_scope', 40)->default('personal_permission')->index(); + $table->dateTime('synced_at', 6); + $table->timestamps(); + + $table->unique( + ['business_id', 'line_no'], + 'ding_process_detail_business_line_unique', + ); + $table->index( + ['business_month', 'document_type'], + 'ding_process_detail_month_type_index', + ); + }); + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + Schema::connection($this->connection())->dropIfExists('ding_process_detail_mirror'); + } +}; diff --git a/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php b/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php new file mode 100644 index 00000000..73f52b29 --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php @@ -0,0 +1,139 @@ +filters($request); + $page = max((int) $request->query('page', 1), 1); + $perPage = 50; + + try { + $result = $this->repository->paginate($filters, $page, $perPage); + $options = $this->repository->filterOptions(); + + return view('business.pages.financial-operations.process-details', [ + 'section' => ['slug' => 'financial-operations', 'title' => '财务经营报表'], + 'page' => ['slug' => 'process-details', 'title' => '流程明细'], + 'rows' => new LengthAwarePaginator( + $result['data'], + $result['total'], + $perPage, + $page, + ['path' => $request->url(), 'query' => $request->except('page')], + ), + 'filters' => $filters, + 'summary' => $result['summary'], + 'months' => $options['months'], + 'documentTypes' => $options['document_types'], + 'statuses' => $options['statuses'], + 'mirrorStatus' => $result['status'], + 'mirrorSyncedAt' => $result['last_synced_at'], + 'scope' => $result['scope'], + 'error' => null, + ]); + } catch (Throwable $exception) { + report($exception); + + return view('business.pages.financial-operations.process-details', [ + 'section' => ['slug' => 'financial-operations', 'title' => '财务经营报表'], + 'page' => ['slug' => 'process-details', 'title' => '流程明细'], + 'rows' => new LengthAwarePaginator([], 0, $perPage, $page, [ + 'path' => $request->url(), + 'query' => $request->except('page'), + ]), + 'filters' => $filters, + 'summary' => [ + 'rows' => 0, + 'approvals' => 0, + 'amount_total' => 0.0, + 'bank_amount_total' => 0.0, + ], + 'months' => [], + 'documentTypes' => [], + 'statuses' => [], + 'mirrorStatus' => 'unavailable', + 'mirrorSyncedAt' => null, + 'scope' => $this->repository->scope(), + 'error' => '流程明细镜像尚未完成导入,请稍后重试。', + ]); + } + } + + public function export(Request $request): StreamedResponse|RedirectResponse + { + if (! $this->repository->ready()) { + return redirect() + ->route('business.pages.show', [ + 'section' => 'financial-operations', + 'page' => 'process-details', + ]) + ->withErrors(['export' => '流程明细镜像尚未完成导入,暂时不能导出。']); + } + + $filters = $this->filters($request); + + return response()->streamDownload(function () use ($filters): void { + $stream = fopen('php://output', 'wb'); + fwrite($stream, "\xEF\xBB\xBF"); + fputcsv($stream, DingProcessDetailMirrorImporter::HEADERS); + + $number = 0; + foreach ($this->repository->exportRows($filters) as $row) { + $number++; + fputcsv($stream, [ + $number, + (string) ($row['business_id'] ?? ''), + (string) ($row['originator_name'] ?? ''), + (string) ($row['status'] ?? ''), + (string) ($row['document_type'] ?? ''), + (string) ($row['purpose'] ?? ''), + (int) ($row['line_no'] ?? 0), + (string) ($row['account_code'] ?? ''), + (string) ($row['account_name'] ?? ''), + $row['amount'] ?? '', + (string) ($row['bank_account_code'] ?? ''), + (string) ($row['bank_account_name'] ?? ''), + $row['bank_amount'] ?? '', + (string) ($row['payment_method'] ?? ''), + (string) ($row['paid_at'] ?? ''), + ]); + } + + fclose($stream); + }, '流程明细_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + /** @return array */ + private function filters(Request $request): array + { + $month = trim((string) $request->query('month', '')); + $status = trim((string) $request->query('status', '')); + + return [ + 'month' => preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '', + 'document_type' => mb_substr(trim((string) $request->query('document_type', '')), 0, 100), + 'status' => mb_substr($status, 0, 20), + 'business_id' => mb_substr(trim((string) $request->query('business_id', '')), 0, 32), + 'originator_name' => mb_substr(trim((string) $request->query('originator_name', '')), 0, 100), + 'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100), + ]; + } +} diff --git a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php new file mode 100644 index 00000000..689e0835 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php @@ -0,0 +1,222 @@ +connection = $database->connection( + (string) config('upstream_mirrors.connection', 'business_mirror') + ); + } + + public function scope(): string + { + $scope = trim((string) config( + 'upstream_mirrors.ding_process_details.scope', + self::DEFAULT_SCOPE, + )); + + return $scope !== '' ? $scope : self::DEFAULT_SCOPE; + } + + public function datasetKey(): string + { + return self::DATASET_PREFIX . $this->scope(); + } + + public function ready(): bool + { + $schema = Schema::connection($this->connection->getName()); + + if (! $schema->hasTable('ding_process_detail_mirror') + || ! $schema->hasTable('business_mirror_table_states')) { + return false; + } + + $state = $this->state(); + + return $state !== null && ($state['initial_completed_at'] ?? null) !== null; + } + + /** @return array|null */ + public function state(): ?array + { + if (! Schema::connection($this->connection->getName())->hasTable('business_mirror_table_states')) { + return null; + } + + $row = $this->connection->table('business_mirror_table_states') + ->where('dataset_key', $this->datasetKey()) + ->first(); + + return $row === null ? null : (array) $row; + } + + /** + * @param array $filters + * @return array{ + * data:list, + * total:int, + * summary:array{rows:int,approvals:int,amount_total:float,bank_amount_total:float}, + * status:string, + * last_synced_at:?string, + * scope:string + * } + */ + public function paginate(array $filters, int $page, int $perPage): array + { + if (! $this->ready()) { + throw new RuntimeException('ding_process_detail_mirror_not_ready'); + } + + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $query = $this->filteredQuery($filters); + + $total = (clone $query)->count(); + $summary = [ + 'rows' => $total, + 'approvals' => (clone $query)->distinct()->count('business_id'), + 'amount_total' => (float) ((clone $query)->sum('amount') ?? 0), + 'bank_amount_total' => (float) ((clone $query)->sum('bank_amount') ?? 0), + ]; + $data = $query + ->orderByDesc('business_month') + ->orderByDesc('business_id') + ->orderBy('line_no') + ->forPage($page, $perPage) + ->get() + ->all(); + $state = $this->state(); + + return [ + 'data' => $data, + 'total' => $total, + 'summary' => $summary, + 'status' => (string) ($state['status'] ?? 'unknown'), + 'last_synced_at' => isset($state['last_succeeded_at']) + ? (string) $state['last_succeeded_at'] + : null, + 'scope' => $this->scope(), + ]; + } + + /** @return array{months:list,document_types:list,statuses:list} */ + public function filterOptions(): array + { + if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) { + return ['months' => [], 'document_types' => [], 'statuses' => []]; + } + + $base = $this->connection->table('ding_process_detail_mirror') + ->where('source_scope', $this->scope()); + + return [ + 'months' => (clone $base) + ->whereNotNull('business_month') + ->where('business_month', '!=', '') + ->distinct() + ->orderByDesc('business_month') + ->pluck('business_month') + ->map(static function (mixed $value): string { + $month = (string) $value; + + return preg_match('/^\d{6}$/', $month) === 1 + ? substr($month, 0, 4) . '-' . substr($month, 4, 2) + : $month; + }) + ->all(), + 'document_types' => (clone $base) + ->whereNotNull('document_type') + ->where('document_type', '!=', '') + ->distinct() + ->orderBy('document_type') + ->pluck('document_type') + ->map(static fn (mixed $value): string => (string) $value) + ->all(), + 'statuses' => (clone $base) + ->whereNotNull('status') + ->where('status', '!=', '') + ->distinct() + ->orderBy('status') + ->pluck('status') + ->map(static fn (mixed $value): string => (string) $value) + ->all(), + ]; + } + + /** @param array $filters */ + public function exportCount(array $filters): int + { + if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) { + return 0; + } + + return (int) $this->filteredQuery($filters)->count(); + } + + /** + * @param array $filters + * @return \Generator> + */ + public function exportRows(array $filters): \Generator + { + $query = $this->filteredQuery($filters) + ->orderByDesc('business_month') + ->orderByDesc('business_id') + ->orderBy('line_no'); + + foreach ($query->cursor() as $row) { + yield (array) $row; + } + } + + /** @param array $filters */ + private function filteredQuery(array $filters): Builder + { + $query = $this->connection->table('ding_process_detail_mirror') + ->where('source_scope', $this->scope()); + + $month = trim((string) ($filters['month'] ?? '')); + if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) { + $query->where('business_month', str_replace('-', '', $month)); + } + + foreach ([ + 'document_type' => 'document_type', + 'status' => 'status', + ] as $filter => $column) { + $value = trim((string) ($filters[$filter] ?? '')); + if ($value !== '') { + $query->where($column, $value); + } + } + + foreach ([ + 'business_id' => 'business_id', + 'originator_name' => 'originator_name', + 'keyword' => 'purpose', + ] as $filter => $column) { + $value = mb_substr(trim((string) ($filters[$filter] ?? '')), 0, 100); + if ($value !== '') { + $query->where($column, 'like', '%' . $value . '%'); + } + } + + return $query; + } +} diff --git a/modules/EcommerceFinance/Routes/admin.php b/modules/EcommerceFinance/Routes/admin.php index 5a08c09f..d128dda9 100644 --- a/modules/EcommerceFinance/Routes/admin.php +++ b/modules/EcommerceFinance/Routes/admin.php @@ -1,7 +1,25 @@ name('index'); }); + +// 通用 workspace/{section}/{page} 路由在 App\Providers\Route::boot() 中注册; +// 模块路由在 register 阶段先注册,因此这里的精确路由会优先命中。沿用 +// business.pages.show 名称,侧边栏仍能生成与通用页面一致的可访问 URL。 +Route::admin('ecommerce-finance', function (): void { + Route::get('workspace/financial-operations/process-details', [ProcessDetails::class, 'show']) + ->middleware('business.page.viewer') + ->defaults('section', 'financial-operations') + ->defaults('page', 'process-details') + ->name('business.pages.show'); + + Route::get('workspace/financial-operations/process-details/export', [ProcessDetails::class, 'export']) + ->middleware('business.page.viewer') + ->defaults('section', 'financial-operations') + ->defaults('page', 'process-details') + ->name('business.process-details.export'); +}, ['prefix' => '', 'as' => '']); diff --git a/modules/EcommerceFinance/Services/DingProcessDetailMirrorImporter.php b/modules/EcommerceFinance/Services/DingProcessDetailMirrorImporter.php new file mode 100644 index 00000000..34e3b434 --- /dev/null +++ b/modules/EcommerceFinance/Services/DingProcessDetailMirrorImporter.php @@ -0,0 +1,282 @@ +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' => null, + '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; + } +} diff --git a/resources/views/business/pages/financial-operations/process-details.blade.php b/resources/views/business/pages/financial-operations/process-details.blade.php new file mode 100644 index 00000000..2d743d7b --- /dev/null +++ b/resources/views/business/pages/financial-operations/process-details.blade.php @@ -0,0 +1,452 @@ +@php + $rows = $rows ?? null; + $filters = $filters ?? []; + $summary = $summary ?? []; + $months = $months ?? []; + $documentTypes = $documentTypes ?? []; + $statuses = $statuses ?? []; + $error = $error ?? null; + $scope = $scope ?? 'personal_permission'; + $scopeLabel = $scope === 'personal_permission' ? '当前账号权限范围' : $scope; + $money = static fn (mixed $value): string => $value === null || $value === '' + ? '-' + : number_format((float) $value, 2); + $text = static fn (mixed $value): string => $value === null || trim((string) $value) === '' + ? '-' + : (string) $value; + $statusClass = static fn (string $status): string => match ($status) { + '已完成' => 'is-success', + '审批中' => 'is-warning', + '已终止', '已撤销' => 'is-danger', + default => 'is-neutral', + }; + $pageUrl = route('business.pages.show', [ + 'section' => 'financial-operations', + 'page' => 'process-details', + ]); + $exportUrl = route('business.process-details.export', array_filter( + $filters, + static fn (mixed $value): bool => $value !== '' && $value !== null, + )); +@endphp + + + + 流程明细 + + + + + +
+
+ 数据范围:{{ $scopeLabel }} + 镜像状态:{{ $mirrorStatus ?? 'unknown' }} + @if (! empty($mirrorSyncedAt)) + 最近同步:{{ $mirrorSyncedAt }} + @endif +
+ + @if ($error) + + @endif + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
明细行数{{ number_format((int) ($summary['rows'] ?? 0)) }}
+
审批单数{{ number_format((int) ($summary['approvals'] ?? 0)) }}
+
金额合计{{ $money($summary['amount_total'] ?? null) }}
+
银行付款合计{{ $money($summary['bank_amount_total'] ?? null) }}
+
+ +

+ 当前展示的是当前账号权限范围内可读取的钉钉审批流程明细;审批中的金额和行项目仍可能随流程变化。 + 企业级全量数据待领导开通 OA 权限后切换。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + @forelse ($rows as $row) + + + + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
序号审批编号发起人状态单据类型用途/摘要单据内行号科目编码科目名称金额(元)银行科目编码银行科目名称银行付款金额付款方式打款时间
{{ ($rows->currentPage() - 1) * $rows->perPage() + $loop->iteration }}{{ $row->business_id }}{{ $text($row->originator_name) }} + {{ $text($row->status) }} + {{ $text($row->document_type) }}{{ $text($row->purpose) }}{{ $row->line_no }}{{ $text($row->account_code) }}{{ $text($row->account_name) }}{{ $money($row->amount) }}{{ $text($row->bank_account_code) }}{{ $text($row->bank_account_name) }}{{ $money($row->bank_amount) }}{{ $text($row->payment_method) }}{{ $text($row->paid_at) }}
+ 当前筛选条件没有流程明细。可以调整月份、单据类型、状态或关键词后重试。 +
+
+ + +
+
+
diff --git a/tests/Feature/Business/ProcessDetailsPageTest.php b/tests/Feature/Business/ProcessDetailsPageTest.php new file mode 100644 index 00000000..3877a7a7 --- /dev/null +++ b/tests/Feature/Business/ProcessDetailsPageTest.php @@ -0,0 +1,133 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(DingProcessDetailMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->andReturn([ + 'data' => [ + (object) [ + 'business_id' => '202601011200000000001', + 'line_no' => 1, + 'originator_name' => '张三', + 'status' => '已完成', + 'document_type' => '公摊日常报销', + 'purpose' => '办公用品采购', + 'account_code' => '5602.04', + 'account_name' => '管理费用_办公费', + 'amount' => 100.00, + 'bank_account_code' => '1002.01.001', + 'bank_account_name' => '华夏银行-测试', + 'bank_amount' => 100.00, + 'payment_method' => '银行卡', + 'paid_at' => '2026-01-02 00:00', + ], + ], + 'total' => 1, + 'summary' => [ + 'rows' => 1, + 'approvals' => 1, + 'amount_total' => 100.0, + 'bank_amount_total' => 100.0, + ], + 'status' => 'succeeded', + 'last_synced_at' => '2026-10-26 15:00:00', + 'scope' => 'personal_permission', + ]); + $repository->shouldReceive('filterOptions')->once()->andReturn([ + 'months' => ['2026-01'], + 'document_types' => ['公摊日常报销'], + 'statuses' => ['已完成'], + ]); + $this->app->instance(DingProcessDetailMirrorRepository::class, $repository); + + $financeSection = collect(\App\Utilities\BusinessNavigation::all()) + ->firstWhere('slug', 'financial-operations'); + $this->assertContains( + 'process-details', + array_column($financeSection['pages'] ?? [], 'slug'), + ); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'financial-operations', + 'page' => 'process-details', + ]))->assertOk() + ->assertSee('流程明细') + ->assertSee('审批编号') + ->assertSee('发起人') + ->assertSee('单据类型') + ->assertSee('银行付款金额') + ->assertSee('202601011200000000001') + ->assertSee('张三') + ->assertSee('管理费用_办公费') + ->assertSee('100.00') + ->assertSee('当前账号权限范围') + ->assertSee('导出'); + } + + public function test_page_stays_readable_when_mirror_is_not_ready(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(DingProcessDetailMirrorRepository::class); + $repository->shouldReceive('paginate')->once()->andThrow(new RuntimeException('not ready')); + $repository->shouldReceive('scope')->once()->andReturn('personal_permission'); + $this->app->instance(DingProcessDetailMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'financial-operations', + 'page' => 'process-details', + ]))->assertOk() + ->assertSee('流程明细镜像尚未完成导入') + ->assertSee('当前账号权限范围'); + } + + public function test_export_streams_filtered_csv(): void + { + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $repository = Mockery::mock(DingProcessDetailMirrorRepository::class); + $repository->shouldReceive('ready')->once()->andReturn(true); + $repository->shouldReceive('exportRows')->once()->andReturnUsing(function (): \Generator { + yield [ + 'business_id' => '202601011200000000001', + 'originator_name' => '张三', + 'status' => '已完成', + 'document_type' => '公摊日常报销', + 'purpose' => '办公用品采购', + 'line_no' => 1, + 'account_code' => '5602.04', + 'account_name' => '管理费用_办公费', + 'amount' => '100.00', + 'bank_account_code' => '1002.01.001', + 'bank_account_name' => '华夏银行-测试', + 'bank_amount' => '100.00', + 'payment_method' => '银行卡', + 'paid_at' => '2026-01-02 00:00', + ]; + }); + $this->app->instance(DingProcessDetailMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route('business.process-details.export', [ + 'status' => '已完成', + ])); + + $response->assertOk(); + $content = $response->streamedContent(); + $this->assertStringContainsString('审批编号', $content); + $this->assertStringContainsString('202601011200000000001', $content); + $this->assertStringContainsString('张三', $content); + $this->assertStringContainsString('管理费用_办公费', $content); + } +} diff --git a/tests/Feature/Console/ImportDingProcessDetailsTest.php b/tests/Feature/Console/ImportDingProcessDetailsTest.php new file mode 100644 index 00000000..c3b036ba --- /dev/null +++ b/tests/Feature/Console/ImportDingProcessDetailsTest.php @@ -0,0 +1,271 @@ + */ + private array $tempFiles = []; + + protected function setUp(): void + { + parent::setUp(); + + config([ + 'database.connections.business_mirror' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.ding_process_details.scope' => 'personal_permission', + ]); + DB::purge('business_mirror'); + $this->createMirrorTables(); + + $this->fixture = base_path('tests/Fixtures/ding_process_details_sample.csv'); + } + + protected function tearDown(): void + { + foreach ($this->tempFiles as $file) { + if (is_file($file)) { + unlink($file); + } + } + + parent::tearDown(); + } + + public function test_imports_csv_and_publishes_mirror_state(): void + { + $result = app(DingProcessDetailMirrorImporter::class) + ->import($this->fixture, 'personal_permission', true); + + $this->assertSame('succeeded', $result['status']); + $this->assertSame(4, $result['rows']); + $this->assertSame(0, $result['invalid']); + $this->assertSame(0, $result['placeholders']); + $this->assertSame(4, DB::connection('business_mirror') + ->table('ding_process_detail_mirror')->count()); + + $row = DB::connection('business_mirror') + ->table('ding_process_detail_mirror') + ->where('business_id', '202601011200000000001') + ->where('line_no', 1) + ->first(); + + $this->assertNotNull($row); + $this->assertSame('张三', $row->originator_name); + $this->assertSame('已完成', $row->status); + $this->assertSame('公摊日常报销', $row->document_type); + $this->assertSame('5602.04', $row->account_code); + $this->assertSame('管理费用_办公费', $row->account_name); + $this->assertEqualsWithDelta(100.0, (float) $row->amount, 0.001); + $this->assertSame('202601', $row->business_month); + $this->assertSame('personal_permission', $row->source_scope); + + $state = DB::connection('business_mirror') + ->table('business_mirror_table_states') + ->where('dataset_key', 'ding.process_details.personal_permission') + ->first(); + + $this->assertNotNull($state); + $this->assertSame('succeeded', $state->status); + $this->assertSame('ding_process_detail_mirror', $state->target_table); + $this->assertNotNull($state->initial_completed_at); + $this->assertSame(4, (int) $state->mirror_row_count); + } + + public function test_import_is_idempotent_by_business_id_and_line_number(): void + { + $importer = app(DingProcessDetailMirrorImporter::class); + $importer->import($this->fixture, 'personal_permission', true); + $importer->import($this->fixture, 'personal_permission', false); + + $this->assertSame(4, DB::connection('business_mirror') + ->table('ding_process_detail_mirror')->count()); + } + + public function test_replace_removes_rows_that_are_not_in_the_new_file(): void + { + $importer = app(DingProcessDetailMirrorImporter::class); + $importer->import($this->fixture, 'personal_permission', true); + + $replacement = $this->makeCsv([[ + '1', + '202603011200000000003', + '王五', + '已完成', + '店铺日常报销', + '替换后的流程', + '1', + '5601.01', + '销售费用', + '10.00', + '1002.01.003', + '银行-测试3', + '10.00', + '银行卡', + '2026-03-02 00:00', + ]]); + + $importer->import($replacement, 'personal_permission', true); + + $this->assertSame(1, DB::connection('business_mirror') + ->table('ding_process_detail_mirror')->count()); + $this->assertSame( + '202603011200000000003', + (string) DB::connection('business_mirror') + ->table('ding_process_detail_mirror') + ->value('business_id'), + ); + } + + public function test_missing_headers_does_not_delete_existing_rows(): void + { + $importer = app(DingProcessDetailMirrorImporter::class); + $importer->import($this->fixture, 'personal_permission', true); + + $path = tempnam(sys_get_temp_dir(), 'ding-process-bad-'); + file_put_contents($path, "序号,审批编号\n1,202603011200000000003\n"); + $this->tempFiles[] = $path; + + try { + $importer->import($path, 'personal_permission', true); + $this->fail('缺少表头时应抛出异常。'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('缺少列', $exception->getMessage()); + } + + $this->assertSame(4, DB::connection('business_mirror') + ->table('ding_process_detail_mirror')->count()); + } + + public function test_placeholder_rows_are_skipped_and_the_better_duplicate_wins(): void + { + $path = $this->makeCsv([ + [ + '1', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '真实费用', + '1', '5602.04', '管理费用_办公费', '100.00', '1002.01.001', '华夏银行-测试', + '100.00', '银行卡', '2026-04-02 00:00', + ], + [ + '2', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '251', + '1', '251', '251', '', '251', '251', '251', '251', '251', + ], + [ + '3', '202604011200000000004', '赵六', '已完成', '公摊日常报销', '251', + '1', '1221.05', '251', '0.00', '1002.01.001', '华夏银行-测试', + '100.00', '银行卡', '2026-04-02 00:00', + ], + [ + '4', '202604021200000000005', '钱七', '已完成', '店铺日常报销', '251', + '1', '251', '251', '', '251', '251', '251', '251', '251', + ], + ]); + + $result = app(DingProcessDetailMirrorImporter::class) + ->import($path, 'personal_permission', true); + + $this->assertSame(1, $result['rows']); + $this->assertSame(2, $result['placeholders']); + $this->assertSame(1, $result['duplicates']); + $row = DB::connection('business_mirror') + ->table('ding_process_detail_mirror') + ->first(); + $this->assertSame('5602.04', $row->account_code); + $this->assertSame('管理费用_办公费', $row->account_name); + $this->assertEqualsWithDelta(100.0, (float) $row->amount, 0.001); + } + + public function test_artisan_command_imports_the_file(): void + { + $this->artisan('ding:import-process-details', [ + 'file' => $this->fixture, + '--scope' => 'personal_permission', + '--replace' => true, + ])->assertSuccessful(); + + $this->assertSame(4, DB::connection('business_mirror') + ->table('ding_process_detail_mirror')->count()); + } + + private function createMirrorTables(): void + { + Schema::connection('business_mirror')->create( + 'business_mirror_table_states', + function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('source_key'); + $table->string('source_object'); + $table->string('target_table'); + $table->string('status')->default('pending'); + $table->string('run_mode')->nullable(); + $table->string('generation')->nullable(); + $table->json('cursor')->nullable(); + $table->json('high_watermark')->nullable(); + $table->dateTime('window_started_at')->nullable(); + $table->dateTime('window_ends_at')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_full_succeeded_at')->nullable(); + $table->dateTime('last_attempted_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->unsignedBigInteger('last_upserted_rows')->default(0); + $table->unsignedBigInteger('mirror_row_count')->default(0); + $table->string('last_error_code')->nullable(); + $table->timestamps(); + }, + ); + + Schema::connection('business_mirror')->create( + 'ding_process_detail_mirror', + function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('business_id', 32); + $table->unsignedInteger('line_no'); + $table->string('business_month', 6)->index(); + $table->string('originator_name', 100)->nullable()->index(); + $table->string('status', 20)->nullable()->index(); + $table->string('document_type', 100)->nullable()->index(); + $table->text('purpose')->nullable(); + $table->string('account_code', 64)->nullable(); + $table->string('account_name', 255)->nullable(); + $table->decimal('amount', 18, 2)->nullable(); + $table->string('bank_account_code', 255)->nullable(); + $table->string('bank_account_name', 255)->nullable(); + $table->decimal('bank_amount', 18, 2)->nullable(); + $table->string('payment_method', 50)->nullable(); + $table->string('paid_at', 50)->nullable(); + $table->string('source_scope', 40)->default('personal_permission')->index(); + $table->dateTime('synced_at', 6); + $table->timestamps(); + $table->unique(['business_id', 'line_no']); + }, + ); + } + + /** @param list> $rows */ + private function makeCsv(array $rows): string + { + $path = tempnam(sys_get_temp_dir(), 'ding-process-'); + $handle = fopen($path, 'wb'); + fputcsv($handle, DingProcessDetailMirrorImporter::HEADERS); + foreach ($rows as $row) { + fputcsv($handle, $row); + } + fclose($handle); + $this->tempFiles[] = $path; + + return $path; + } +} diff --git a/tests/Fixtures/ding_process_details_sample.csv b/tests/Fixtures/ding_process_details_sample.csv new file mode 100644 index 00000000..64f7a2e4 --- /dev/null +++ b/tests/Fixtures/ding_process_details_sample.csv @@ -0,0 +1,5 @@ +序号,审批编号,发起人,状态,单据类型,用途/摘要,单据内行号,科目编码,科目名称,金额(元),银行科目编码,银行科目名称,银行付款金额,付款方式,打款时间 +1,202601011200000000001,张三,已完成,公摊日常报销,办公用品采购,1,5602.04,管理费用_办公费,100.00,1002.01.001,华夏银行-测试,100.00,银行卡,2026-01-02 00:00 +2,202601011200000000001,张三,已完成,公摊日常报销,交通费,2,5602.06,管理费用_交通费,50.50,1002.01.001,华夏银行-测试,50.50,银行卡,2026-01-02 00:00 +3,202602031200000000002,李四,审批中,应付申请流程,供应商货款,1,,,200.00,1002.01.002,银行-测试2,200.00,银行卡, +4,202602031200000000002,李四,审批中,应付申请流程,供应商货款,2,,,300.00,1002.01.002,银行-测试2,300.00,银行卡, diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php new file mode 100644 index 00000000..45746aa2 --- /dev/null +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php @@ -0,0 +1,233 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.ding_process_details.scope' => 'personal_permission', + ]); + DB::purge('business_mirror'); + $this->createMirrorTables(); + $this->seedRows(); + + $this->repository = app(DingProcessDetailMirrorRepository::class); + } + + public function test_paginate_returns_rows_summary_and_filter_options(): void + { + $result = $this->repository->paginate([], 1, 50); + + $this->assertSame(4, $result['total']); + $this->assertCount(4, $result['data']); + $this->assertSame(4, $result['summary']['rows']); + $this->assertSame(2, $result['summary']['approvals']); + $this->assertEqualsWithDelta(650.5, $result['summary']['amount_total'], 0.001); + $this->assertEqualsWithDelta(650.5, $result['summary']['bank_amount_total'], 0.001); + $this->assertSame('personal_permission', $result['scope']); + + $options = $this->repository->filterOptions(); + $this->assertSame(['2026-02', '2026-01'], $options['months']); + $this->assertContains('公摊日常报销', $options['document_types']); + $this->assertContains('应付申请流程', $options['document_types']); + $this->assertContains('已完成', $options['statuses']); + $this->assertContains('审批中', $options['statuses']); + } + + public function test_paginate_applies_month_status_and_keyword_filters(): void + { + $this->assertSame( + 2, + $this->repository->paginate(['month' => '2026-01'], 1, 50)['total'], + ); + $this->assertSame( + 2, + $this->repository->paginate(['status' => '审批中'], 1, 50)['total'], + ); + $this->assertSame( + 1, + $this->repository->paginate(['keyword' => '办公'], 1, 50)['total'], + ); + } + + public function test_paginate_applies_document_type_originator_and_business_id_filters(): void + { + $this->assertSame( + 2, + $this->repository->paginate([ + 'document_type' => '应付申请流程', + 'originator_name' => '李四', + ], 1, 50)['total'], + ); + $this->assertSame( + 2, + $this->repository->paginate(['business_id' => '0002'], 1, 50)['total'], + ); + } + + public function test_export_rows_returns_every_filtered_row(): void + { + $rows = iterator_to_array($this->repository->exportRows(['status' => '已完成'])); + + $this->assertCount(2, $rows); + $this->assertSame('202601011200000000001', (string) $rows[0]['business_id']); + } + + public function test_not_ready_when_state_is_missing(): void + { + DB::connection('business_mirror') + ->table('business_mirror_table_states') + ->where('dataset_key', 'ding.process_details.personal_permission') + ->delete(); + + $this->assertFalse($this->repository->ready()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ding_process_detail_mirror_not_ready'); + $this->repository->paginate([], 1, 50); + } + + private function seedRows(): void + { + $now = now(); + $rows = [ + $this->row('202601011200000000001', 1, '202601', '张三', '已完成', '公摊日常报销', '办公用品采购', 100.00), + $this->row('202601011200000000001', 2, '202601', '张三', '已完成', '公摊日常报销', '交通费', 50.50), + $this->row('202602031200000000002', 1, '202602', '李四', '审批中', '应付申请流程', '供应商货款', 200.00), + $this->row('202602031200000000002', 2, '202602', '李四', '审批中', '应付申请流程', '供应商货款', 300.00), + ]; + + DB::connection('business_mirror') + ->table('ding_process_detail_mirror') + ->insert($rows); + + DB::connection('business_mirror') + ->table('business_mirror_table_states') + ->insert([ + 'dataset_key' => 'ding.process_details.personal_permission', + 'source_key' => 'dingtalk_oa', + 'source_object' => 'oa approval detail (personal permission scope)', + 'target_table' => 'ding_process_detail_mirror', + 'status' => 'succeeded', + 'run_mode' => 'full', + 'generation' => '00000000-0000-0000-0000-000000000001', + 'initial_completed_at' => $now, + 'last_full_succeeded_at' => $now, + 'last_attempted_at' => $now, + 'last_succeeded_at' => $now, + 'last_upserted_rows' => 4, + 'mirror_row_count' => 4, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + /** @return array */ + private function row( + string $businessId, + int $lineNo, + string $month, + string $originator, + string $status, + string $documentType, + string $purpose, + float $amount, + ): array { + $now = now(); + + return [ + 'business_id' => $businessId, + 'line_no' => $lineNo, + 'business_month' => $month, + 'originator_name' => $originator, + 'status' => $status, + 'document_type' => $documentType, + 'purpose' => $purpose, + 'account_code' => '5602.04', + 'account_name' => '管理费用_办公费', + 'amount' => $amount, + 'bank_account_code' => '1002.01.001', + 'bank_account_name' => '华夏银行-测试', + 'bank_amount' => $amount, + 'payment_method' => '银行卡', + 'paid_at' => '2026-01-02 00:00', + 'source_scope' => 'personal_permission', + 'synced_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + private function createMirrorTables(): void + { + Schema::connection('business_mirror')->create( + 'business_mirror_table_states', + function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('source_key'); + $table->string('source_object'); + $table->string('target_table'); + $table->string('status')->default('pending'); + $table->string('run_mode')->nullable(); + $table->string('generation')->nullable(); + $table->json('cursor')->nullable(); + $table->json('high_watermark')->nullable(); + $table->dateTime('window_started_at')->nullable(); + $table->dateTime('window_ends_at')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_full_succeeded_at')->nullable(); + $table->dateTime('last_attempted_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->unsignedBigInteger('last_upserted_rows')->default(0); + $table->unsignedBigInteger('mirror_row_count')->default(0); + $table->string('last_error_code')->nullable(); + $table->timestamps(); + }, + ); + + Schema::connection('business_mirror')->create( + 'ding_process_detail_mirror', + function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('business_id', 32); + $table->unsignedInteger('line_no'); + $table->string('business_month', 6)->index(); + $table->string('originator_name', 100)->nullable()->index(); + $table->string('status', 20)->nullable()->index(); + $table->string('document_type', 100)->nullable()->index(); + $table->text('purpose')->nullable(); + $table->string('account_code', 64)->nullable(); + $table->string('account_name', 255)->nullable(); + $table->decimal('amount', 18, 2)->nullable(); + $table->string('bank_account_code', 255)->nullable(); + $table->string('bank_account_name', 255)->nullable(); + $table->decimal('bank_amount', 18, 2)->nullable(); + $table->string('payment_method', 50)->nullable(); + $table->string('paid_at', 50)->nullable(); + $table->string('source_scope', 40)->default('personal_permission')->index(); + $table->dateTime('synced_at', 6); + $table->timestamps(); + $table->unique(['business_id', 'line_no']); + }, + ); + } +} -- 2.47.2