From 891b708140f7681738416f1d6faf1369e8407d5f Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Fri, 30 Oct 2026 10:15:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3OA=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=97=B6=E5=8C=BA=E4=B8=8E=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/activate-incremental-release.sh | 8 ++ deploy/activate-release.sh | 8 ++ docs/上游业务镜像清单.md | 2 +- .../DingProcessDetailMirrorRepository.php | 7 +- .../DingProcessDetailMirrorSynchronizer.php | 12 +-- .../Support/OaProcessTime.php | 77 +++++++++++++++++++ .../process-details-content.blade.php | 53 ++----------- .../Business/ProcessDetailsPageTest.php | 2 +- .../Deployment/BusinessMirrorScheduleTest.php | 13 ++++ .../DingProcessDetailMirrorRepositoryTest.php | 26 +++++++ ...ingProcessDetailMirrorSynchronizerTest.php | 8 +- .../EcommerceFinance/OaProcessTimeTest.php | 68 ++++++++++++++++ 12 files changed, 220 insertions(+), 64 deletions(-) create mode 100644 modules/EcommerceFinance/Support/OaProcessTime.php create mode 100644 tests/Unit/EcommerceFinance/OaProcessTimeTest.php diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 38bdc078..4d532121 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -244,6 +244,14 @@ CRON_TZ=Asia/Shanghai EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots +# OA 流程明细单独使用 5 分钟增量任务,避免被共用上游锁中的分钟级任务长期饿死; +# 每周全量校准仍保留在上面的主计划中。 +cat > /etc/cron.d/caiwu-ding-process-details <<'EOF' +CRON_TZ=Asia/Shanghai +2-59/5 * * * * root CAIWU_BATCH_LOCK_WAIT=900 /usr/bin/flock -n /run/caiwu-ding-process-details.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --quiet >/dev/null 2>&1 +EOF +chmod 644 /etc/cron.d/caiwu-ding-process-details + docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \ | awk '!seen[$0]++' \ | tail -n "+$((KEEP_IMAGES + 1))" \ diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index ac6e5f93..8914cec8 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -248,6 +248,14 @@ CRON_TZ=Asia/Shanghai EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots +# OA 流程明细单独使用 5 分钟增量任务,避免被共用上游锁中的分钟级任务长期饿死; +# 每周全量校准仍保留在上面的主计划中。 +cat > /etc/cron.d/caiwu-ding-process-details <<'EOF' +CRON_TZ=Asia/Shanghai +2-59/5 * * * * root CAIWU_BATCH_LOCK_WAIT=900 /usr/bin/flock -n /run/caiwu-ding-process-details.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --quiet >/dev/null 2>&1 +EOF +chmod 644 /etc/cron.d/caiwu-ding-process-details + docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \ | awk '!seen[$0]++' \ | tail -n "+$((KEEP_IMAGES + 1))" \ diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index f6c3e0f6..b1344c15 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 分钟。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。 +流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布脚本会覆盖安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟;`scripts/install-ding-process-sync-cron.ps1` 仅作为手工修复入口。任务时间和镜像状态统一使用平台固定时区,审批等待时长按 12 小时区间展示。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。 `operations_replica` 缺失付款组件或进行中流程需要刷新出纳节点时,同步器复用现有钉钉应用凭据调用 `https://api.dingtalk.com/v1.0/workflow/processInstances`。官方接口同时返回表单、`tasks.taskGroupName` 和 `operationRecords.showName`,用于识别“出纳打款”节点及退回/待处理/已通过状态。付款来源标记为 `api`,付款事实仍进入 `ding_process_payment_mirror`;不会用官方接口覆盖副本已有的业务明细,只补缺失或空的付款字段,并独立保存“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”,`cashier_checked_at` 用于记录近期已检查时间。自动增量每 5 分钟按 `process_instance_id` 游标轮转最多 50 个缺失或需要刷新出纳状态的流程,避免单个失败记录阻塞队列或瞬时打满接口;人工核查可执行 `business-mirror:sync-ding-process-details --business-id=审批编号1,审批编号2 --dry-run`,确认后再去掉 `--dry-run`。单次最多 50 个审批编号。 diff --git a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php index 636299be..689b474f 100644 --- a/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/DingProcessDetailMirrorRepository.php @@ -7,6 +7,7 @@ use Illuminate\Database\DatabaseManager; use Illuminate\Database\Query\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Schema; +use Modules\EcommerceFinance\Support\OaProcessTime; use RuntimeException; class DingProcessDetailMirrorRepository @@ -436,8 +437,10 @@ class DingProcessDetailMirrorRepository return true; } - return Carbon::parse((string) $state['last_succeeded_at']) - ->lt(now()->subMinutes($staleAfterMinutes)); + $lastSucceededAt = OaProcessTime::parse($state['last_succeeded_at']); + + return $lastSucceededAt === null + || $lastSucceededAt->lt(OaProcessTime::now()->subMinutes($staleAfterMinutes)); } private function structuredPaymentsEnabled(): bool diff --git a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php index 5f50ed71..3b65fcd4 100644 --- a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Modules\EcommerceFinance\Exceptions\DingTalkProcessInstanceException; +use Modules\EcommerceFinance\Support\OaProcessTime; use RuntimeException; use Throwable; @@ -1532,16 +1533,9 @@ class DingProcessDetailMirrorSynchronizer private function taskDateTime(mixed $value): ?Carbon { - $text = trim((string) $value); - if ($text === '') { - return null; - } + $parsed = OaProcessTime::parse($value); - try { - return Carbon::parse(str_replace('T', ' ', preg_replace('/Z$/', '', $text))); - } catch (Throwable) { - return null; - } + return $parsed === null ? null : Carbon::instance($parsed); } private function isCashierLabel(string $label): bool diff --git a/modules/EcommerceFinance/Support/OaProcessTime.php b/modules/EcommerceFinance/Support/OaProcessTime.php new file mode 100644 index 00000000..c538b1c7 --- /dev/null +++ b/modules/EcommerceFinance/Support/OaProcessTime.php @@ -0,0 +1,77 @@ +setTimezone(self::timezone()); + } catch (Throwable) { + return null; + } + } + + public static function formatDateTime(mixed $value): string + { + return self::parse($value)?->format('Y-m-d H:i') ?? '-'; + } + + public static function formatWaiting(mixed $value, ?CarbonInterface $now = null): string + { + $startedAt = self::parse($value); + if ($startedAt === null) { + return '-'; + } + + $nowAt = $now === null + ? self::now() + : CarbonImmutable::instance($now)->setTimezone(self::timezone()); + if ($startedAt->greaterThan($nowAt)) { + return '-'; + } + + $hours = intdiv($startedAt->diffInSeconds($nowAt), 3600); + $steps = intdiv($hours, self::WAIT_STEP_HOURS); + if ($steps < 1) { + return '不足12小时'; + } + + $days = intdiv($steps, 2); + $halfDays = $steps % 2; + $parts = []; + if ($days > 0) { + $parts[] = $days . '天'; + } + if ($halfDays > 0) { + $parts[] = self::WAIT_STEP_HOURS . '小时'; + } + + return implode('', $parts); + } +} 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 aec0ad3b..e0bfe045 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 @@ -33,51 +33,10 @@ '已付款' => '已确认付款合计', default => '付款明细合计', }; - $dateTimeText = static function (mixed $value): string { - $text = trim((string) $value); - if ($text === '') { - return '-'; - } - - try { - return \Carbon\Carbon::parse($text)->format('Y-m-d H:i'); - } catch (\Throwable) { - return $text; - } - }; - $waitText = static function (mixed $value): string { - $text = trim((string) $value); - if ($text === '') { - return '-'; - } - - try { - $startedAt = \Carbon\Carbon::parse($text); - $seconds = $startedAt->greaterThan(now()) ? 0 : $startedAt->diffInSeconds(now()); - } catch (\Throwable) { - return '-'; - } - if ($seconds < 60) { - return '少于1分钟'; - } - - $minutes = intdiv($seconds, 60); - $days = intdiv($minutes, 1440); - $hours = intdiv($minutes % 1440, 60); - $remainingMinutes = $minutes % 60; - $parts = []; - if ($days > 0) { - $parts[] = $days . '天'; - } - if ($hours > 0) { - $parts[] = $hours . '小时'; - } - if ($remainingMinutes > 0 || $parts === []) { - $parts[] = $remainingMinutes . '分钟'; - } - - return implode('', $parts); - }; + $dateTimeText = static fn (mixed $value): string => + \Modules\EcommerceFinance\Support\OaProcessTime::formatDateTime($value); + $waitText = static fn (mixed $value): string => + \Modules\EcommerceFinance\Support\OaProcessTime::formatWaiting($value); $currentApproverText = static function (object $row): string { $approver = trim((string) ($row->current_approver_name ?? '')); if ($approver !== '') { @@ -677,7 +636,7 @@ 数据范围:{{ $scopeLabel }} 镜像状态:{{ $mirrorStatus ?? 'unknown' }} @if (! empty($mirrorSyncedAt)) - 最近同步:{{ $mirrorSyncedAt }} + 最近同步:{{ \Modules\EcommerceFinance\Support\OaProcessTime::formatDateTime($mirrorSyncedAt) }} @endif @@ -807,7 +766,7 @@ 同一笔付款不会因关联多条费用而重复计算。 付款状态按审批状态和已抓取的结构化付款事实判断;付款差额按“费用合计 − 付款明细合计”单独计算,不参与付款状态判断。 审批中的流程在出现结构化付款事实前显示“待付款”,已有付款事实时显示“已付款”。 - 当前审批人、进入时间和已等待时长来自钉钉在途任务同步,后台会持续补查未完成的审批流程。 + 当前审批人、进入时间和已等待时长来自钉钉在途任务同步,后台会持续补查未完成的审批流程;已等待按 12 小时区间显示。 当前展示的是当前账号权限范围内可读取的钉钉OA审批流程;审批中的金额和行项目仍可能随流程变化。 企业级全量数据待领导开通 OA 权限后切换。

diff --git a/tests/Feature/Business/ProcessDetailsPageTest.php b/tests/Feature/Business/ProcessDetailsPageTest.php index 7be1ac83..eddbf59c 100644 --- a/tests/Feature/Business/ProcessDetailsPageTest.php +++ b/tests/Feature/Business/ProcessDetailsPageTest.php @@ -106,7 +106,7 @@ class ProcessDetailsPageTest extends FeatureTestCase ->assertDontSee('财务用途审批') ->assertDontSee('出纳打款 · 已完成') ->assertSee('王五') - ->assertSee('1小时30分钟') + ->assertSee('不足12小时') ->assertSee('付款明细合计') ->assertSee('已付款') ->assertDontSee('付款待核实') diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index d4768521..1d91038a 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -141,6 +141,19 @@ class BusinessMirrorScheduleTest extends TestCase $contents, $script, ); + $this->assertStringContainsString( + "cat > /etc/cron.d/caiwu-ding-process-details <<'EOF'\n" + . "CRON_TZ=Asia/Shanghai\n" + . '2-59/5 * * * * root CAIWU_BATCH_LOCK_WAIT=900 /usr/bin/flock -n /run/caiwu-ding-process-details.lock ' + . '/bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --quiet >/dev/null 2>&1', + $contents, + $script, + ); + $this->assertStringContainsString( + 'chmod 644 /etc/cron.d/caiwu-ding-process-details', + $contents, + $script, + ); } } diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php index fa22dffb..4246ba88 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorRepositoryTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\EcommerceFinance; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Repositories\DingProcessDetailMirrorRepository; @@ -96,6 +97,31 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase ); } + public function test_mirror_staleness_uses_platform_timezone(): void + { + config([ + 'app.timezone' => 'Europe/London', + 'app.platform_timezone' => 'Asia/Shanghai', + ]); + Carbon::setTestNow(Carbon::parse('2026-10-30 10:00:00', 'Asia/Shanghai')); + + try { + DB::connection('business_mirror') + ->table('business_mirror_table_states') + ->where('dataset_key', 'ding.process_details.personal_permission') + ->update(['last_succeeded_at' => '2026-10-30 09:50:00']); + $this->assertFalse($this->repository->paginate([], 1, 50)['sync_stale']); + + DB::connection('business_mirror') + ->table('business_mirror_table_states') + ->where('dataset_key', 'ding.process_details.personal_permission') + ->update(['last_succeeded_at' => '2026-10-30 09:44:59']); + $this->assertTrue($this->repository->paginate([], 1, 50)['sync_stale']); + } finally { + Carbon::setTestNow(); + } + } + public function test_paginate_applies_paid_date_range_status_and_keyword_filters(): void { $this->assertSame( diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php index becc497c..f3e5028b 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php @@ -282,9 +282,9 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase ->first(); $this->assertSame('财务审批 / 出纳打款', $row->current_task_name); $this->assertSame('王五', $row->current_approver_name); - $this->assertSame('2026-06-03 15:00:00', (string) $row->current_task_started_at); + $this->assertSame('2026-06-03 23:00:00', (string) $row->current_task_started_at); $this->assertSame('出纳打款', $row->cashier_task_name); - $this->assertSame('2026-06-03 15:34:00', (string) $row->cashier_task_started_at); + $this->assertSame('2026-06-03 23:34:00', (string) $row->cashier_task_started_at); $this->assertSame('pending', $row->cashier_status); $this->assertSame( '待付款', @@ -332,7 +332,7 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase ->first(); $this->assertSame('出纳打款', $row->current_task_name); $this->assertSame('王五', $row->current_approver_name); - $this->assertSame('2026-10-29 18:50:00', (string) $row->current_task_started_at); + $this->assertSame('2026-10-30 02:50:00', (string) $row->current_task_started_at); } public function test_running_workflow_without_payment_fields_resolves_current_task_and_approver(): void @@ -380,7 +380,7 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase ->first(); $this->assertSame('行政核价', $row->current_task_name); $this->assertSame('赵六', $row->current_approver_name); - $this->assertSame('2026-10-29 19:20:00', (string) $row->current_task_started_at); + $this->assertSame('2026-10-30 03:20:00', (string) $row->current_task_started_at); $this->assertSame('not_applicable', $row->payment_source_state); } diff --git a/tests/Unit/EcommerceFinance/OaProcessTimeTest.php b/tests/Unit/EcommerceFinance/OaProcessTimeTest.php new file mode 100644 index 00000000..48075f25 --- /dev/null +++ b/tests/Unit/EcommerceFinance/OaProcessTimeTest.php @@ -0,0 +1,68 @@ + 'Europe/London', + 'app.platform_timezone' => 'Asia/Shanghai', + ]); + } + + #[DataProvider('waitingDurationProvider')] + public function test_waiting_duration_uses_twelve_hour_steps( + string $startedAt, + string $expected, + ): void { + $now = CarbonImmutable::parse('2026-10-30 12:00:00', 'Asia/Shanghai'); + + $this->assertSame($expected, OaProcessTime::formatWaiting($startedAt, $now)); + } + + public static function waitingDurationProvider(): array + { + return [ + 'less than first step' => ['2026-10-30 00:01:00', '不足12小时'], + 'first step' => ['2026-10-30 00:00:00', '12小时'], + 'one day' => ['2026-10-29 12:00:00', '1天'], + 'one and a half days' => ['2026-10-29 00:00:00', '1天12小时'], + 'two days' => ['2026-10-28 12:00:00', '2天'], + 'three days' => ['2026-10-27 12:00:00', '3天'], + ]; + } + + public function test_task_timestamp_is_converted_from_utc_to_platform_timezone(): void + { + $this->assertSame( + '2026-10-30 02:50', + OaProcessTime::formatDateTime('2026-10-29T18:50:00Z'), + ); + $this->assertSame( + '12小时', + OaProcessTime::formatWaiting( + '2026-10-29T18:50:00Z', + CarbonImmutable::parse('2026-10-30 14:50:00', 'Asia/Shanghai'), + ), + ); + } + + public function test_future_timestamp_is_not_shown_as_less_than_one_minute(): void + { + $now = CarbonImmutable::parse('2026-10-30 12:00:00', 'Asia/Shanghai'); + + $this->assertSame( + '-', + OaProcessTime::formatWaiting('2026-10-30 12:01:00', $now), + ); + } +} -- 2.47.2