From c8a941e08513f061aab83ce2bc67f7c0d38ae1f1 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Mon, 2 Nov 2026 16:54:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=B9=E5=B9=B3OA=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E6=9C=88=E4=BB=BD=E4=B8=8E=E8=B4=B9=E7=94=A8=E6=98=8E=E7=BB=86?= =?UTF-8?q?=20(#824)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/upstream_mirrors.php | 4 + ...000_rebuild_ding_process_payment_lines.php | 87 +++++++++++++++++ .../DingProcessDetailMirrorSynchronizer.php | 16 ++++ .../DingProcessPaymentLineAllocator.php | 34 +++++-- ...ingProcessDetailMirrorSynchronizerTest.php | 39 ++++++++ .../DingProcessPaymentLineAllocatorTest.php | 94 +++++++++++++++++++ 6 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_11_02_200000_rebuild_ding_process_payment_lines.php diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index bec72c1e..7984ed81 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -115,12 +115,14 @@ return [ 'detail_table' => '收款费用明细', 'detail_amount_fields' => ['收款金额'], 'amount_total_fields' => ['收款合计'], + 'payment_table' => '付款明细', 'payment_total_fields' => ['付款合计'], ], 'PROC-C014F61F-F1F8-4F36-93F8-DF2A7EEAC457' => [ 'document_type' => '外贸发货费用报销', 'scalar_detail' => true, 'detail_amount_fields' => ['收款金额'], + 'payment_table' => '付款明细', 'payment_total_fields' => ['付款合计'], ], 'PROC-1E002C79-C087-4630-B9CE-FAB6DF80FAC5' => [ @@ -128,6 +130,7 @@ return [ 'detail_table' => '工资明细表', 'detail_amount_fields' => ['付款金额'], 'amount_total_fields' => ['收款合计'], + 'payment_table' => '出纳填写工资付款明细', 'payment_total_fields' => ['付款合计'], ], 'PROC-2C1EFBA6-2369-461C-8CCD-05A3664CB8B5' => [ @@ -135,6 +138,7 @@ return [ 'detail_table' => '收款费用明细', 'detail_amount_fields' => ['收款金额'], 'amount_total_fields' => ['收款合计'], + 'payment_table' => '付款明细', 'payment_total_fields' => ['付款合计'], ], ], diff --git a/database/migrations/2026_11_02_200000_rebuild_ding_process_payment_lines.php b/database/migrations/2026_11_02_200000_rebuild_ding_process_payment_lines.php new file mode 100644 index 00000000..3adb7d97 --- /dev/null +++ b/database/migrations/2026_11_02_200000_rebuild_ding_process_payment_lines.php @@ -0,0 +1,87 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('ding_process_detail_mirror') + || ! $schema->hasTable('ding_process_payment_mirror') + || ! $schema->hasTable('ding_process_payment_line_mirror')) { + return; + } + + $schema->getConnection()->transaction(function (): void { + $database = Schema::connection($this->connection())->getConnection(); + $database->table('ding_process_payment_line_mirror')->delete(); + $this->backfill($database); + }); + } + + public function down(): void + { + // 该表是可从审批明细和付款事实重建的派生镜像,无需删除现有数据。 + } + + private function backfill(ConnectionInterface $database): void + { + $allocator = app(DingProcessPaymentLineAllocator::class); + $now = now(); + $scopes = $database->table('ding_process_payment_mirror') + ->distinct() + ->pluck('source_scope'); + + foreach ($scopes as $scopeValue) { + $scope = trim((string) $scopeValue); + if ($scope === '') { + continue; + } + + $lastId = 0; + do { + $payments = $database->table('ding_process_payment_mirror') + ->where('source_scope', $scope) + ->where('id', '>', $lastId) + ->orderBy('id') + ->limit(500) + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + if ($payments === []) { + break; + } + $lastId = (int) end($payments)['id']; + + $businessIds = array_values(array_unique(array_filter(array_map( + static fn (array $row): string => trim((string) ($row['business_id'] ?? '')), + $payments, + )))); + $details = $database->table('ding_process_detail_mirror') + ->where('source_scope', $scope) + ->whereIn('business_id', $businessIds) + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + $rows = $allocator->allocate($details, $payments, $scope, $now); + foreach (array_chunk($rows, 500) as $chunk) { + $database->table('ding_process_payment_line_mirror')->insert($chunk); + } + } while (count($payments) === 500); + } + } +}; diff --git a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php index f8a7ae98..43c88a1e 100644 --- a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php @@ -509,6 +509,10 @@ class DingProcessDetailMirrorSynchronizer 'ding_process_detail_mirror', 'account_code', ); + $paymentDateRefreshEnabled = Schema::connection($this->connection->getName())->hasColumn( + 'ding_process_detail_mirror', + 'paid_at', + ); $remainingAttempts = self::APPROVAL_API_DRAIN_LIMIT - $this->approvalApiAttempts; if (! $this->approvalApiAvailable || $remainingAttempts <= 0) { return []; @@ -528,6 +532,7 @@ class DingProcessDetailMirrorSynchronizer $cashierRefreshEnabled, $approverRefreshEnabled, $accountCodeRefreshEnabled, + $paymentDateRefreshEnabled, $cashierStaleBefore, ): void { $query->where('payment_source_state', 'missing'); @@ -555,6 +560,17 @@ class DingProcessDetailMirrorSynchronizer ->orWhere('account_code', ''); }); } + if ($paymentDateRefreshEnabled) { + // 已完成审批已有付款事实但日期为空,通常来自历史同步配置 + // 未读取付款明细表。轮转刷新可自动补回真实打款月份。 + $query->orWhere(function ($paymentDate): void { + $paymentDate->where('status', '已完成') + ->whereIn('payment_source_state', ['present', 'api', 'missing']) + ->where(function ($paidAt): void { + $paidAt->whereNull('paid_at')->orWhere('paid_at', ''); + }); + }); + } }) ->whereNotNull('process_instance_id') ->where('process_instance_id', '!=', ''); diff --git a/modules/EcommerceFinance/Services/DingProcessPaymentLineAllocator.php b/modules/EcommerceFinance/Services/DingProcessPaymentLineAllocator.php index c09a63ce..0d91fd75 100644 --- a/modules/EcommerceFinance/Services/DingProcessPaymentLineAllocator.php +++ b/modules/EcommerceFinance/Services/DingProcessPaymentLineAllocator.php @@ -43,7 +43,8 @@ final class DingProcessPaymentLineAllocator $lineNumbers = $this->lineNumbers( (string) ($payment['sequence_spec'] ?? ''), - array_keys($businessDetails), + $businessDetails, + $amountCents, ); if ($lineNumbers === []) { continue; @@ -95,24 +96,41 @@ final class DingProcessPaymentLineAllocator } /** - * @param list $availableLines + * @param array $businessDetails * @return list */ - private function lineNumbers(string $sequenceSpec, array $availableLines): array + private function lineNumbers( + string $sequenceSpec, + array $businessDetails, + int $amountCents, + ): array { + $availableLines = array_map('intval', array_keys($businessDetails)); + sort($availableLines); + if ($availableLines === []) { + return []; + } + preg_match_all('/\d+/', $sequenceSpec, $matches); $requested = array_values(array_unique(array_map('intval', $matches[0] ?? []))); $matched = array_values(array_intersect($requested, $availableLines)); if ($matched !== []) { return $matched; } - if ($requested !== []) { - return []; + + $exactLines = []; + foreach ($businessDetails as $lineNo => $detail) { + if ((int) $detail['amount_cents'] === $amountCents) { + $exactLines[] = (int) $lineNo; + } + } + if (count($exactLines) === 1) { + return $exactLines; } - sort($availableLines); - - return $availableLines === [] ? [] : [$availableLines[0]]; + // 序号为空或引用了不存在的明细行时,仍按全部明细金额分摊, + // 保证每一笔已付款都能进入对应审批,不因单行数据错误整笔漏计。 + return $availableLines; } /** diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php index 44e2194d..5c1e3887 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php @@ -1089,6 +1089,45 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase ); } + public function test_completed_payment_row_with_blank_paid_at_is_queued_for_refresh(): void + { + $now = now(); + DB::connection('business_mirror')->table('ding_process_detail_mirror')->insert([ + 'business_id' => '202610301116000560927', + 'process_instance_id' => 'process-payment-date-refresh', + 'line_no' => 1, + 'business_month' => '202610', + 'originator_name' => '周生辰', + 'status' => '已完成', + 'approval_finished_at' => '2026-11-02 13:03:00', + 'payment_source_state' => 'present', + 'cashier_status' => 'completed', + 'cashier_checked_at' => $now, + 'current_approver_name' => '已审批', + 'document_type' => '线上代发业务报销', + 'purpose' => '10.29种菜报销', + 'account_code' => '1221.01', + 'account_name' => '其他应收款_运营中心-种菜刷单', + 'amount' => 100, + 'paid_at' => '', + 'source_scope' => 'personal_permission', + 'synced_at' => $now->copy()->subHour(), + 'created_at' => $now->copy()->subDay(), + 'updated_at' => $now->copy()->subHour(), + ]); + + $method = new \ReflectionMethod( + DingProcessDetailMirrorSynchronizer::class, + 'approvalApiFallbackCandidates', + ); + $method->setAccessible(true); + + $this->assertSame( + ['process-payment-date-refresh'], + $method->invoke($this->synchronizer, '', [], 10), + ); + } + public function test_incremental_sync_drains_cashier_backlog_before_changed_workflows(): void { $connection = DB::connection('business_mirror'); diff --git a/tests/Unit/EcommerceFinance/DingProcessPaymentLineAllocatorTest.php b/tests/Unit/EcommerceFinance/DingProcessPaymentLineAllocatorTest.php index e7d1d56c..be940b99 100644 --- a/tests/Unit/EcommerceFinance/DingProcessPaymentLineAllocatorTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessPaymentLineAllocatorTest.php @@ -98,4 +98,98 @@ class DingProcessPaymentLineAllocatorTest extends TestCase Carbon::setTestNow(); } } + + public function test_it_allocates_an_invalid_sequence_to_the_unique_matching_detail(): void + { + $rows = (new DingProcessPaymentLineAllocator())->allocate( + [ + ['business_id' => 'approval-3', 'line_no' => 1, 'amount' => '100.00'], + ['business_id' => 'approval-3', 'line_no' => 2, 'amount' => '200.00'], + ], + [[ + 'business_id' => 'approval-3', + 'process_instance_id' => 'process-3', + 'payment_no' => 1, + 'sequence_spec' => '9', + 'amount' => '200.00', + 'paid_at' => '2026-10-15 00:00', + ]], + 'personal_permission', + Carbon::parse('2026-11-02 12:00:00'), + ); + + $this->assertSame( + [[2, 200.0]], + array_map( + static fn (array $row): array => [ + (int) $row['line_no'], + (float) $row['amount'], + ], + $rows, + ), + ); + } + + public function test_it_allocates_a_payment_without_a_sequence_across_all_details(): void + { + $rows = (new DingProcessPaymentLineAllocator())->allocate( + [ + ['business_id' => 'approval-4', 'line_no' => 1, 'amount' => '100.00'], + ['business_id' => 'approval-4', 'line_no' => 2, 'amount' => '200.00'], + ], + [[ + 'business_id' => 'approval-4', + 'process_instance_id' => 'process-4', + 'payment_no' => 1, + 'sequence_spec' => '', + 'amount' => '300.00', + 'paid_at' => '2026-10-15 00:00', + ]], + 'personal_permission', + Carbon::parse('2026-11-02 12:00:00'), + ); + + $this->assertSame( + [[1, 100.0], [2, 200.0]], + array_map( + static fn (array $row): array => [ + (int) $row['line_no'], + (float) $row['amount'], + ], + $rows, + ), + ); + } + + public function test_it_does_not_drop_a_payment_when_the_sequence_and_amount_do_not_match(): void + { + $rows = (new DingProcessPaymentLineAllocator())->allocate( + [ + ['business_id' => 'approval-5', 'line_no' => 1, 'amount' => '100.00'], + ['business_id' => 'approval-5', 'line_no' => 2, 'amount' => '200.00'], + ], + [[ + 'business_id' => 'approval-5', + 'process_instance_id' => 'process-5', + 'payment_no' => 1, + 'sequence_spec' => '9', + 'amount' => '150.00', + 'paid_at' => '2026-10-15 00:00', + ]], + 'personal_permission', + Carbon::parse('2026-11-02 12:00:00'), + ); + + $this->assertSame( + [[1, 50.0], [2, 100.0]], + array_map( + static fn (array $row): array => [ + (int) $row['line_no'], + (float) $row['amount'], + ], + $rows, + ), + ); + $this->assertSame(150.0, array_sum(array_column($rows, 'amount'))); + } }