fix: 修正OA等待时间时区与镜像调度 #781

Merged
yueyang merged 1 commits from fix/yueyang-oa-wait-timezone into main 2026-10-30 10:18:58 +08:00
12 changed files with 220 additions and 64 deletions

View File

@ -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))" \

View File

@ -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))" \

View File

@ -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 个审批编号。

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,77 @@
<?php
namespace Modules\EcommerceFinance\Support;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Throwable;
final class OaProcessTime
{
private const WAIT_STEP_HOURS = 12;
public static function timezone(): string
{
$timezone = trim((string) config('app.platform_timezone', ''));
return $timezone !== '' ? $timezone : 'Asia/Shanghai';
}
public static function now(): CarbonImmutable
{
return CarbonImmutable::now(self::timezone());
}
public static function parse(mixed $value): ?CarbonImmutable
{
$text = trim((string) $value);
if ($text === '') {
return null;
}
try {
return CarbonImmutable::parse($text, self::timezone())
->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);
}
}

View File

@ -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 @@
<span>数据范围:<strong>{{ $scopeLabel }}</strong></span>
<span>镜像状态:<strong>{{ $mirrorStatus ?? 'unknown' }}</strong></span>
@if (! empty($mirrorSyncedAt))
<span>最近同步:<strong>{{ $mirrorSyncedAt }}</strong></span>
<span>最近同步:<strong>{{ \Modules\EcommerceFinance\Support\OaProcessTime::formatDateTime($mirrorSyncedAt) }}</strong></span>
@endif
</div>
@ -807,7 +766,7 @@
同一笔付款不会因关联多条费用而重复计算。
付款状态按审批状态和已抓取的结构化付款事实判断;付款差额按“费用合计 − 付款明细合计”单独计算,不参与付款状态判断。
审批中的流程在出现结构化付款事实前显示“待付款”,已有付款事实时显示“已付款”。
当前审批人、进入时间和已等待时长来自钉钉在途任务同步,后台会持续补查未完成的审批流程。
当前审批人、进入时间和已等待时长来自钉钉在途任务同步,后台会持续补查未完成的审批流程;已等待按 12 小时区间显示。
当前展示的是当前账号权限范围内可读取的钉钉OA审批流程;审批中的金额和行项目仍可能随流程变化。
企业级全量数据待领导开通 OA 权限后切换。
</p>

View File

@ -106,7 +106,7 @@ class ProcessDetailsPageTest extends FeatureTestCase
->assertDontSee('财务用途审批')
->assertDontSee('出纳打款 · 已完成')
->assertSee('王五')
->assertSee('1小时30分钟')
->assertSee('不足12小时')
->assertSee('付款明细合计')
->assertSee('已付款')
->assertDontSee('付款待核实')

View File

@ -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,
);
}
}

View File

@ -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(

View File

@ -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);
}

View File

@ -0,0 +1,68 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Carbon\CarbonImmutable;
use Modules\EcommerceFinance\Support\OaProcessTime;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class OaProcessTimeTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'app.timezone' => '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),
);
}
}