From 3be3f59855278d73a32e5c4f3f1ecb710eaefaee Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Tue, 27 Oct 2026 11:15:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E4=BB=98=E6=AC=BE=E6=98=8E=E7=BB=86=E8=A2=AB=E8=AF=AF=E5=88=A4?= =?UTF-8?q?=E4=B8=BA=E6=9C=AA=E4=BB=98=E6=AC=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ce_state_to_ding_process_detail_mirror.php | 47 +++++ docs/上游业务镜像清单.md | 2 +- .../Http/Controllers/ProcessDetails.php | 2 +- .../DingProcessDetailMirrorRepository.php | 37 +++- .../DingProcessDetailMirrorSynchronizer.php | 178 +++++++++++++++++- .../View/ProcessDetailsPageData.php | 2 +- .../process-details-content.blade.php | 4 +- .../Business/ProcessDetailsPageTest.php | 3 +- .../DingProcessDetailMirrorRepositoryTest.php | 67 ++++++- ...ingProcessDetailMirrorSynchronizerTest.php | 98 +++++++++- .../ProcessDetailsPageDataTest.php | 7 +- 11 files changed, 429 insertions(+), 18 deletions(-) create mode 100644 database/migrations/2026_10_27_110000_add_payment_source_state_to_ding_process_detail_mirror.php diff --git a/database/migrations/2026_10_27_110000_add_payment_source_state_to_ding_process_detail_mirror.php b/database/migrations/2026_10_27_110000_add_payment_source_state_to_ding_process_detail_mirror.php new file mode 100644 index 00000000..a361e72a --- /dev/null +++ b/database/migrations/2026_10_27_110000_add_payment_source_state_to_ding_process_detail_mirror.php @@ -0,0 +1,47 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + if ($schema->hasTable('ding_process_detail_mirror') + && ! $schema->hasColumn('ding_process_detail_mirror', 'payment_source_state')) { + $schema->table('ding_process_detail_mirror', function (Blueprint $table): void { + $table->string('payment_source_state', 24) + ->nullable() + ->after('status'); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + if ($schema->hasTable('ding_process_detail_mirror') + && $schema->hasColumn('ding_process_detail_mirror', 'payment_source_state')) { + $schema->table('ding_process_detail_mirror', function (Blueprint $table): void { + $table->dropColumn('payment_source_state'); + }); + } + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 814a28a2..595fd496 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -35,7 +35,7 @@ | 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `operations_replica.rpt.jst_sku`;`rpt.fys_skuCostPriceUpdate` 是一 SKU 一行的旧快照,不能充当变价历史。完整成本价变更仍读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;经典接口必须在服务器私有配置中使用同一应用的 `JUSHUITAN_PARTNER_ID / JUSHUITAN_PARTNER_KEY / JUSHUITAN_TOKEN`,不得把新开放平台的 App Key / App Secret / Access Token 混用。页面在首次同步完成前明确显示待同步或失败,不把 0 条解释为真实无历史。 | | 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror` 与 `ding_process_payment_mirror`。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 | -流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布后由工作站执行 `scripts/install-ding-process-sync-cron.ps1` 安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟。页面同时按审批单费用合计和独立付款事实展示“未付款 / 部分付款 / 已付款”;审批状态不能代替付款状态。 +流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布后由工作站执行 `scripts/install-ding-process-sync-cron.ps1` 安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。页面按审批单费用合计和独立付款事实展示“付款明细待同步 / 未付款 / 部分付款 / 已付款 / 不适用”:只有源表单明确存在付款表或付款合计字段但金额为零时才标“未付款”,付款表单缺失时必须显示“付款明细待同步”,不能把上游尚未返回的数据误报为未付款。审批状态不能代替付款状态。 抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。 diff --git a/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php b/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php index 5ef1ae8b..a3f6a798 100644 --- a/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php +++ b/modules/EcommerceFinance/Http/Controllers/ProcessDetails.php @@ -135,7 +135,7 @@ class ProcessDetails extends Controller && checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1]); $paymentState = trim((string) $request->query('payment_state', '')); if (! in_array($paymentState, DingProcessDetailMirrorRepository::PAYMENT_STATES, true) - && $paymentState !== '未确认') { + && $paymentState !== DingProcessDetailMirrorRepository::LEGACY_PAYMENT_STATE) { $paymentState = ''; } diff --git a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php index 720d6350..05f7a6f2 100644 --- a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php @@ -15,10 +15,14 @@ class DingProcessDetailMirrorRepository public const DATASET_PREFIX = 'ding.process_details.'; - public const PAYMENT_STATES = ['未付款', '部分付款', '已付款']; + public const PAYMENT_STATES = ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用']; + + public const LEGACY_PAYMENT_STATE = '未确认'; private readonly ConnectionInterface $connection; + private ?bool $paymentSourceStateColumn = null; + public function __construct(DatabaseManager $database) { $this->connection = $database->connection( @@ -164,7 +168,7 @@ class DingProcessDetailMirrorRepository ->all(), 'payment_states' => $this->structuredPaymentsEnabled() ? self::PAYMENT_STATES - : ['未确认'], + : [self::LEGACY_PAYMENT_STATE], ]; } @@ -233,7 +237,10 @@ class DingProcessDetailMirrorRepository ); } else { $query - ->selectRaw('NULL as payment_fact_amount, ? as payment_state', ['未确认']); + ->selectRaw( + 'NULL as payment_fact_amount, ? as payment_state', + [self::LEGACY_PAYMENT_STATE], + ); } return $this->applyFilters($query, $filters); @@ -274,9 +281,9 @@ class DingProcessDetailMirrorRepository if ($paymentState !== '') { $allowed = $this->structuredPaymentsEnabled() ? self::PAYMENT_STATES - : ['未确认']; + : [self::LEGACY_PAYMENT_STATE]; if (in_array($paymentState, $allowed, true)) { - if ($paymentState !== '未确认') { + if ($paymentState !== self::LEGACY_PAYMENT_STATE) { $query->whereRaw( '(' . $this->paymentStateExpression() . ') = ?', [$paymentState], @@ -290,14 +297,28 @@ class DingProcessDetailMirrorRepository private function paymentStateExpression(): string { + $sourceState = $this->paymentSourceStateColumnAvailable() + ? 'ding_process_detail_mirror.payment_source_state' + : "'missing'"; + return "CASE - WHEN COALESCE(payment_totals.payment_amount, 0) <= 0.005 THEN '未付款' - WHEN payment_totals.payment_amount + 0.01 + WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 + AND payment_totals.payment_amount + 0.01 < COALESCE(detail_totals.approval_amount, 0) THEN '部分付款' - ELSE '已付款' + WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 THEN '已付款' + WHEN {$sourceState} = 'not_applicable' THEN '不适用' + WHEN {$sourceState} = 'present' THEN '未付款' + ELSE '付款明细待同步' END"; } + private function paymentSourceStateColumnAvailable(): bool + { + return $this->paymentSourceStateColumn ??= Schema::connection( + $this->connection->getName(), + )->hasColumn('ding_process_detail_mirror', 'payment_source_state'); + } + /** @param array|null $state */ private function stateIsStale(?array $state, int $staleAfterMinutes): bool { diff --git a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php index 35cdb4ff..4591593a 100644 --- a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php @@ -142,6 +142,7 @@ class DingProcessDetailMirrorSynchronizer */ private function extractFullWindow(Carbon $now): array { + $schemeHighWatermark = $this->schemeHighWatermark(); $start = trim((string) config('upstream_mirrors.ding_process_details.start_date', '2026-01-01')); $end = $now->copy()->addDay()->toDateString(); $where = 'w.joinAt >= ? AND w.joinAt < ?'; @@ -163,6 +164,7 @@ class DingProcessDetailMirrorSynchronizer $result['metrics']['workflows'], )); } + $result['metrics']['scheme_high_watermark'] = $schemeHighWatermark; return $result; } @@ -239,6 +241,7 @@ class DingProcessDetailMirrorSynchronizer */ private function extractIncremental(?array $state, Carbon $now): array { + $schemeHighWatermark = $this->schemeHighWatermark(); $overlapHours = max( 1, (int) config('upstream_mirrors.ding_process_details.incremental_overlap_hours', 48), @@ -250,13 +253,145 @@ class DingProcessDetailMirrorSynchronizer $where = $cursorExpression . ' >= ? AND ' . $cursorExpression . ' < ?'; $bindings = [$since, $until]; - return $this->extractWorkflows( + $cursor = $this->stateCursor($state); + $lastSchemeId = max(0, (int) ($cursor['scheme_high_watermark'] ?? 0)); + $result = $this->extractWorkflows( $where, $bindings, $cursorExpression, 'sync_cursor', $now, ); + + if ($lastSchemeId > 0) { + $changedProcessIds = $this->schemeChangedProcessIds( + $lastSchemeId, + $schemeHighWatermark, + ); + if ($changedProcessIds !== []) { + foreach (array_chunk($changedProcessIds, 500) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + $schemeResult = $this->extractWorkflows( + "w.processInstanceId IN ({$placeholders})", + $chunk, + 'w.processInstanceId', + 'processInstanceId', + $now, + ); + $result = $this->mergeExtracted($result, $schemeResult); + } + } + } + + $result['metrics']['scheme_high_watermark'] = $schemeHighWatermark; + + return $result; + } + + /** + * New scheme rows can be appended after the workflow itself stopped + * changing. Track their numeric ID so late payment/details are discovered + * without rescanning every workflow. + * + * @return list + */ + private function schemeChangedProcessIds(int $afterId, int $throughId): array + { + if ($throughId <= $afterId) { + return []; + } + + $processCodes = array_keys((array) config( + 'upstream_mirrors.ding_process_details.processes', + [], + )); + $placeholders = implode(',', array_fill(0, count($processCodes), '?')); + $query = $this->queries->forSourceWithTimeout( + self::SOURCE, + (int) config('finance_ledger.query_timeout', 120), + ); + $rows = $query->select( + 'SELECT DISTINCT s.processInstanceId ' + . 'FROM rpt.ding_WorkflowScheme s ' + . 'JOIN rpt.ding_Workflow w ON w.processInstanceId = s.processInstanceId ' + . 'WHERE TRY_CONVERT(bigint, s.id) > ? ' + . 'AND TRY_CONVERT(bigint, s.id) <= ? ' + . "AND w.processCode IN ({$placeholders}) " + . 'ORDER BY s.processInstanceId', + [$afterId, $throughId, ...$processCodes], + ); + + return array_values(array_filter(array_map( + static fn (array $row): string => trim((string) ($row['processInstanceId'] ?? '')), + $rows, + ))); + } + + private function schemeHighWatermark(): int + { + $query = $this->queries->forSourceWithTimeout( + self::SOURCE, + (int) config('finance_ledger.query_timeout', 120), + ); + + return max(0, (int) $query->scalar( + 'SELECT MAX(TRY_CONVERT(bigint, id)) FROM rpt.ding_WorkflowScheme', + )); + } + + /** @param array|null $state @return array */ + private function stateCursor(?array $state): array + { + $cursor = $state['cursor'] ?? null; + if (is_string($cursor)) { + $cursor = json_decode($cursor, true); + } + + return is_array($cursor) ? $cursor : []; + } + + /** + * @param array{details:list>,payments:list>,process_instance_ids:list,metrics:array} $first + * @param array{details:list>,payments:list>,process_instance_ids:list,metrics:array} $second + * @return array{details:list>,payments:list>,process_instance_ids:list,metrics:array} + */ + private function mergeExtracted(array $first, array $second): array + { + if ($second['process_instance_ids'] === []) { + return $first; + } + + $details = []; + foreach ([...$first['details'], ...$second['details']] as $row) { + $key = (string) ($row['business_id'] ?? '') . '|' . (int) ($row['line_no'] ?? 0); + $details[$key] = $row; + } + + $payments = []; + foreach ([...$first['payments'], ...$second['payments']] as $row) { + $key = (string) ($row['business_id'] ?? '') . '|' . (int) ($row['payment_no'] ?? 0); + $payments[$key] = $row; + } + + $details = array_values($details); + $payments = array_values($payments); + $processInstanceIds = array_values(array_unique([ + ...$first['process_instance_ids'], + ...$second['process_instance_ids'], + ])); + + return [ + 'details' => $details, + 'payments' => $payments, + 'process_instance_ids' => $processInstanceIds, + 'metrics' => [ + 'workflows' => count($processInstanceIds), + 'details' => count($details), + 'payments' => count($payments), + 'amount_total' => array_sum(array_column($details, 'amount_cents')) / 100, + 'bank_amount_total' => array_sum(array_column($payments, 'amount_cents')) / 100, + ], + ]; } /** @@ -507,6 +642,7 @@ class DingProcessDetailMirrorSynchronizer $documentType = $this->documentType($process, $title); $status = $this->status((string) ($workflow['status'] ?? '')); $businessMonth = $this->businessMonth($businessId, (string) ($workflow['joinAt'] ?? '')); + $paymentSourceState = $this->paymentSourceState($process, $fields); [$workflowDetails, $workflowAmountCents, $detailTableName] = $this->detailRows( $workflow, @@ -519,6 +655,7 @@ class DingProcessDetailMirrorSynchronizer $documentType, $status, $businessMonth, + $paymentSourceState, ); $this->validateAmountTotal( $businessId, @@ -553,6 +690,7 @@ class DingProcessDetailMirrorSynchronizer $documentType, $status, $businessMonth, + $paymentSourceState, ); } @@ -595,6 +733,7 @@ class DingProcessDetailMirrorSynchronizer string $documentType, string $status, string $businessMonth, + string $paymentSourceState, ): array { $tableName = trim((string) ($process['detail_table'] ?? '')); $rows = $tableName !== '' ? $this->tableRows($fields[$tableName] ?? null, $workflow, $tableName, $issues) : []; @@ -626,6 +765,7 @@ class DingProcessDetailMirrorSynchronizer $documentType, $status, $businessMonth, + $paymentSourceState, ); } @@ -748,6 +888,39 @@ class DingProcessDetailMirrorSynchronizer return $payments; } + /** + * Distinguish an empty payment table from a payment form section that the + * upstream replica has not delivered yet. Missing sections must never be + * presented as "未付款". + * + * @param array $process + * @param array $fields + */ + private function paymentSourceState(array $process, array $fields): string + { + $paymentTable = trim((string) ($process['payment_table'] ?? '')); + $paymentTotalFields = array_values(array_filter(array_map( + static fn (mixed $value): string => trim((string) $value), + (array) ($process['payment_total_fields'] ?? []), + ))); + + if ($paymentTable === '' && $paymentTotalFields === []) { + return 'not_applicable'; + } + + if ($paymentTable !== '' && array_key_exists($paymentTable, $fields)) { + return 'present'; + } + + foreach ($paymentTotalFields as $field) { + if (array_key_exists($field, $fields)) { + return 'present'; + } + } + + return 'missing'; + } + /** * @param string $businessId * @param array $process @@ -840,6 +1013,7 @@ class DingProcessDetailMirrorSynchronizer string $documentType, string $status, string $businessMonth, + string $paymentSourceState, ): array { return [ 'business_id' => mb_substr(trim((string) ($workflow['businessId'] ?? '')), 0, 32), @@ -852,6 +1026,7 @@ class DingProcessDetailMirrorSynchronizer 'business_month' => $businessMonth, 'originator_name' => mb_substr($originator, 0, 100), 'status' => mb_substr($status, 0, 20), + 'payment_source_state' => mb_substr($paymentSourceState, 0, 24), 'document_type' => mb_substr($documentType, 0, 100), 'purpose' => $purpose, 'account_code' => mb_substr($accountCode, 0, 64), @@ -1182,6 +1357,7 @@ class DingProcessDetailMirrorSynchronizer 'mirror_payments' => $paymentCount, 'amount_total' => round($amountTotal, 2), 'bank_amount_total' => round($bankAmountTotal, 2), + 'scheme_high_watermark' => (int) ($metrics['scheme_high_watermark'] ?? 0), ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'high_watermark' => null, 'window_started_at' => null, diff --git a/modules/EcommerceFinance/View/ProcessDetailsPageData.php b/modules/EcommerceFinance/View/ProcessDetailsPageData.php index eba03f7d..eaaa432e 100644 --- a/modules/EcommerceFinance/View/ProcessDetailsPageData.php +++ b/modules/EcommerceFinance/View/ProcessDetailsPageData.php @@ -81,7 +81,7 @@ class ProcessDetailsPageData && checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1]); $paymentState = trim((string) $request->query('payment_state', '')); if (! in_array($paymentState, DingProcessDetailMirrorRepository::PAYMENT_STATES, true) - && $paymentState !== '未确认') { + && $paymentState !== DingProcessDetailMirrorRepository::LEGACY_PAYMENT_STATE) { $paymentState = ''; } diff --git a/resources/views/business/pages/financial-operations/process-details-content.blade.php b/resources/views/business/pages/financial-operations/process-details-content.blade.php index 7ae34cae..cd7c5666 100644 --- a/resources/views/business/pages/financial-operations/process-details-content.blade.php +++ b/resources/views/business/pages/financial-operations/process-details-content.blade.php @@ -5,7 +5,8 @@ $paymentStateClass = static fn (string $state): string => match ($state) { '已付款' => 'is-success', '部分付款' => 'is-warning', - '未付款', '未确认' => 'is-neutral', + '付款明细待同步', '未确认' => 'is-warning', + '未付款', '不适用' => 'is-neutral', default => 'is-neutral', }; @endphp @@ -612,6 +613,7 @@

金额合计按审批费用明细汇总,付款合计按结构化付款明细汇总,同一笔付款不会因关联多条费用而重复计算。 付款状态按每张审批单的费用合计与独立付款事实判断,不能仅用审批状态推断是否已付款。 + “付款明细待同步”表示上游表单尚未返回付款明细,不能据此认定未付款。 当前展示的是当前账号权限范围内可读取的钉钉审批流程明细;审批中的金额和行项目仍可能随流程变化。 企业级全量数据待领导开通 OA 权限后切换。

diff --git a/tests/Feature/Business/ProcessDetailsPageTest.php b/tests/Feature/Business/ProcessDetailsPageTest.php index c1c1c57d..9748db9f 100644 --- a/tests/Feature/Business/ProcessDetailsPageTest.php +++ b/tests/Feature/Business/ProcessDetailsPageTest.php @@ -52,7 +52,7 @@ class ProcessDetailsPageTest extends FeatureTestCase $repository->shouldReceive('filterOptions')->once()->andReturn([ 'document_types' => ['公摊日常报销'], 'statuses' => ['已完成'], - 'payment_states' => ['未付款', '部分付款', '已付款'], + 'payment_states' => ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'], ]); $this->app->instance(DingProcessDetailMirrorRepository::class, $repository); @@ -77,6 +77,7 @@ class ProcessDetailsPageTest extends FeatureTestCase ->assertSee('审批状态') ->assertSee('付款状态') ->assertSee('未付款') + ->assertSee('付款明细待同步') ->assertSee('付款金额') ->assertSee('付款合计') ->assertDontSee('银行付款合计') diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php index 6e69c070..8db5281a 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php @@ -55,7 +55,10 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase $this->assertContains('应付申请流程', $options['document_types']); $this->assertContains('已完成', $options['statuses']); $this->assertContains('审批中', $options['statuses']); - $this->assertSame(['未付款', '部分付款', '已付款'], $options['payment_states']); + $this->assertSame( + ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'], + $options['payment_states'], + ); } public function test_paginate_applies_paid_date_status_and_keyword_filters(): void @@ -101,6 +104,66 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase ); } + public function test_missing_payment_source_is_not_reported_as_unpaid(): void + { + DB::connection('business_mirror') + ->table('ding_process_payment_mirror') + ->where('business_id', '202601011200000000001') + ->delete(); + DB::connection('business_mirror') + ->table('ding_process_detail_mirror') + ->where('business_id', '202601011200000000001') + ->update(['payment_source_state' => 'missing']); + + $result = $this->repository->paginate( + ['payment_state' => '付款明细待同步'], + 1, + 50, + ); + + $this->assertSame(2, $result['total']); + $this->assertSame( + ['付款明细待同步'], + collect($result['data'])->pluck('payment_state')->unique()->values()->all(), + ); + } + + public function test_present_empty_payment_table_is_reported_as_unpaid(): void + { + DB::connection('business_mirror') + ->table('ding_process_payment_mirror') + ->where('business_id', '202602031200000000002') + ->delete(); + + $result = $this->repository->paginate(['payment_state' => '未付款'], 1, 50); + + $this->assertSame(2, $result['total']); + $this->assertSame( + ['未付款'], + collect($result['data'])->pluck('payment_state')->unique()->values()->all(), + ); + } + + public function test_process_without_payment_fields_is_not_applicable(): void + { + DB::connection('business_mirror') + ->table('ding_process_payment_mirror') + ->where('business_id', '202601011200000000001') + ->delete(); + DB::connection('business_mirror') + ->table('ding_process_detail_mirror') + ->where('business_id', '202601011200000000001') + ->update(['payment_source_state' => 'not_applicable']); + + $result = $this->repository->paginate(['payment_state' => '不适用'], 1, 50); + + $this->assertSame(2, $result['total']); + $this->assertSame( + ['不适用'], + collect($result['data'])->pluck('payment_state')->unique()->values()->all(), + ); + } + public function test_export_rows_returns_every_filtered_row(): void { $rows = iterator_to_array($this->repository->exportRows(['status' => '已完成'])); @@ -193,6 +256,7 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase 'business_month' => $month, 'originator_name' => $originator, 'status' => $status, + 'payment_source_state' => 'present', 'document_type' => $documentType, 'purpose' => $purpose, 'account_code' => '5602.04', @@ -246,6 +310,7 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase $table->string('business_month', 6)->index(); $table->string('originator_name', 100)->nullable()->index(); $table->string('status', 20)->nullable()->index(); + $table->string('payment_source_state', 24)->nullable(); $table->string('document_type', 100)->nullable()->index(); $table->text('purpose')->nullable(); $table->string('account_code', 64)->nullable(); diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php index 0c4c4ff0..8a2a7d19 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php @@ -95,6 +95,8 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase ->table('ding_process_payment_mirror')->count()); $this->assertEqualsWithDelta(1114.57, (float) DB::connection('business_mirror') ->table('ding_process_detail_mirror')->sum('bank_amount'), 0.001); + $this->assertSame('present', DB::connection('business_mirror') + ->table('ding_process_detail_mirror')->value('payment_source_state')); $summary = app(DingProcessDetailMirrorRepository::class) ->paginate(['status' => '已完成'], 1, 50)['summary']; @@ -170,6 +172,74 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase $this->assertNotNull($row); $this->assertNull($row->amount); $this->assertSame('审批中', $row->status); + $this->assertSame('missing', $row->payment_source_state); + $page = app(DingProcessDetailMirrorRepository::class)->paginate( + ['payment_state' => '付款明细待同步'], + 1, + 50, + ); + $this->assertSame(1, $page['total']); + } + + public function test_incremental_sync_detects_late_payment_rows_without_workflow_update(): void + { + $workflow = [ + 'processInstanceId' => 'process-late-payment', + 'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF', + 'businessId' => '202610151743000310225', + 'title' => '李斯提交的线上代发业务报销', + 'status' => 'RUNNING', + 'originatorUserId' => 'user-1', + 'joinAt' => '2026-10-15 17:44:06', + 'updateAt' => '2026-10-15 17:44:06', + ]; + $this->query->workflows = [$workflow]; + $this->query->schemeHighWatermark = 100; + $this->query->scheme = [ + '收款费用明细' => json_encode([[ + 'rowValue' => [ + ['label' => '代发订单金额', 'value' => '15535.37'], + ['label' => '科目编码', 'value' => '5601.05.03'], + ['label' => '科目名称', 'value' => '销售费用_营销费用_软件服务费'], + ], + ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + '收款合计' => 15535.37, + ]; + $this->synchronizer->sync(true); + $this->assertSame('missing', DB::connection('business_mirror') + ->table('ding_process_detail_mirror')->value('payment_source_state')); + + DB::connection('business_mirror') + ->table('business_mirror_table_states') + ->where('dataset_key', 'ding.process_details.personal_permission') + ->update(['last_succeeded_at' => now()->subMinutes(10)]); + $this->query->workflows = []; + $this->query->schemeChangedWorkflows = [$workflow]; + $this->query->schemeChangedProcessIds = ['process-late-payment']; + $this->query->schemeHighWatermark = 101; + $this->query->scheme['付款明细'] = json_encode([[ + 'rowValue' => [ + ['label' => '对应序号', 'value' => '1'], + ['label' => '付款方式', 'value' => '银行卡'], + ['label' => '付款金额', 'value' => '15535.37'], + ['label' => '科目编码', 'value' => '1002.01.544'], + ['label' => '科目名称', 'value' => '华夏银行-福尼达'], + ['label' => '打款时间', 'value' => '2026-10-18'], + ], + ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + $result = $this->synchronizer->sync(false); + + $this->assertSame(1, $result['workflows']); + $this->assertSame(1, $result['payments']); + $this->assertSame('present', DB::connection('business_mirror') + ->table('ding_process_detail_mirror')->value('payment_source_state')); + $this->assertSame(1, DB::connection('business_mirror') + ->table('ding_process_payment_mirror')->count()); + $state = DB::connection('business_mirror') + ->table('business_mirror_table_states') + ->first(); + $this->assertSame(101, (int) json_decode((string) $state->cursor, true)['scheme_high_watermark']); } /** @return array */ @@ -240,6 +310,7 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase $table->string('business_month', 6); $table->string('originator_name', 100)->nullable(); $table->string('status', 20)->nullable(); + $table->string('payment_source_state', 24)->nullable(); $table->string('document_type', 100)->nullable(); $table->text('purpose')->nullable(); $table->string('account_code', 64)->nullable(); @@ -287,6 +358,13 @@ class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery /** @var array */ public array $scheme = []; + public array $schemeChangedWorkflows = []; + + /** @var list */ + public array $schemeChangedProcessIds = []; + + public int $schemeHighWatermark = 0; + /** @var list */ public array $calls = []; @@ -297,6 +375,9 @@ class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery public function scalar(string $sql, array $bindings = []): mixed { + if (str_contains($sql, 'MAX(TRY_CONVERT(bigint, id))')) { + return $this->schemeHighWatermark; + } if (str_contains($sql, 'COUNT(*)') && str_contains($sql, 'rpt.ding_Workflow')) { return count($this->workflows); } @@ -311,18 +392,33 @@ class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery if (str_contains($sql, 'FROM rpt.hr_employee')) { return [['userid' => 'user-1', 'name' => '陈芝豹']]; } + if (str_contains($sql, 'SELECT DISTINCT s.processInstanceId')) { + return array_map( + static fn (string $id): array => ['processInstanceId' => $id], + $this->schemeChangedProcessIds, + ); + } if (str_contains($sql, 'JOIN rpt.ding_WorkflowScheme') && str_contains($sql, 'NOT IN')) { return []; } + if (str_contains($sql, 'FROM rpt.ding_Workflow w') + && str_contains($sql, 'w.processInstanceId IN')) { + return $this->schemeChangedWorkflows; + } if (str_contains($sql, 'FROM rpt.ding_Workflow w')) { return $this->workflows; } if (str_contains($sql, 'FROM rpt.ding_WorkflowScheme')) { $rows = []; + $processInstanceId = (string) ( + $this->workflows[0]['processInstanceId'] + ?? $this->schemeChangedWorkflows[0]['processInstanceId'] + ?? '' + ); foreach ($this->scheme as $name => $value) { $rows[] = [ - 'processInstanceId' => (string) ($this->workflows[0]['processInstanceId'] ?? ''), + 'processInstanceId' => $processInstanceId, 'name' => $name, 'value' => $value, ]; diff --git a/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php b/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php index 4db44521..b59eb505 100644 --- a/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php +++ b/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php @@ -68,7 +68,7 @@ class ProcessDetailsPageDataTest extends TestCase ->willReturn([ 'document_types' => ['种菜核销/种菜报销'], 'statuses' => ['审批中'], - 'payment_states' => ['未付款', '部分付款', '已付款'], + 'payment_states' => ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'], ]); $data = (new ProcessDetailsPageData($repository))->page(Request::create( @@ -86,7 +86,10 @@ class ProcessDetailsPageDataTest extends TestCase $this->assertSame(11331, $data['summary']['rows']); $this->assertSame('2026-10-24', $data['filters']['paid_date']); $this->assertSame('未付款', $data['filters']['payment_state']); - $this->assertSame(['未付款', '部分付款', '已付款'], $data['paymentStates']); + $this->assertSame( + ['付款明细待同步', '未付款', '部分付款', '已付款', '不适用'], + $data['paymentStates'], + ); $this->assertTrue($data['mirrorStale']); $this->assertSame(15, $data['mirrorStaleAfterMinutes']); $this->assertArrayNotHasKey('months', $data);