diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 04496d3d..15cef123 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -53,8 +53,14 @@ class Kernel extends ConsoleKernel ->withoutOverlapping(60) ->runInBackground(); + // 退款待确认的当前状态按分钟增量刷新,确认完成后页面可及时移除。 + $schedule->command('business-mirror:sync-tables jiansun.dbo.jst_refund_view') + ->everyMinute() + ->withoutOverlapping(5) + ->runInBackground(); + // 退款待确认四张镜像每天 06:00 专属刷新,确保 10:00 前完成; - // 不再参与分钟级通用轮转,失败状态由财务驾驶舱展示。 + // 当前退款状态单独按分钟刷新,失败状态由财务驾驶舱展示。 $schedule->command('business-mirror:sync-tables --daily-only --max-batches=0') ->dailyAt('06:00') ->timezone('Asia/Shanghai') diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index be4d2f88..b26eb56a 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -325,14 +325,15 @@ return [ 'full_reconcile_hours' => (int) env('BUSINESS_MIRROR_FULL_RECONCILE_HOURS', 168), 'dedicated_datasets' => [ 'operations_replica.rpt.jst_sku', + 'jiansun.dbo.jst_refund_view', ], - // 退款待确认:每天 10 点前由专属批次刷新一次,不再进入分钟轮转。 + // 四张退款基础镜像每天 10 点前由专属批次刷新一次。 + // 聚水潭当前退款状态按分钟增量刷新,保证退款确认后页面及时更新。 'daily_datasets' => [ 'operations_replica.rpt.jst_returnAndRefundInfo', 'operations_replica.rpt.jst_returnAndRefundItem', 'operations_replica.rpt.jst_qmcust_refundlist', 'operations_replica.rpt.jst_qmcust_refundlist_item', - 'jiansun.dbo.jst_refund_view', ], ], @@ -491,6 +492,7 @@ return [ 'keys' => ['as_id'], 'watermark' => 'modified', 'minimum_datetime' => ['column' => 'as_date', 'value' => '2026-10-01 00:00:00'], + 'overlap_hours' => 1, 'full_reconcile_hours' => 168, 'columns' => ['as_id', 'status', 'shop_status', 'confirm_date', 'modified', 'as_date'], 'indexes' => [ diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index b0211444..b9d925f6 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -200,7 +200,7 @@ mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha" # 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。 cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF' CRON_TZ=Asia/Shanghai -* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 +* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables jiansun.dbo.jst_refund_view --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 # 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。 7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1 # 店铺候选平时仅读镜像变更;夜间全量核对历史名称。冷页面不扫描订单宽表。 @@ -230,7 +230,8 @@ CRON_TZ=Asia/Shanghai 15 10 * * * root /usr/bin/flock -n /run/caiwu-inventory-warehouse-alert.lock /usr/bin/docker exec -u www-data caiwu_app php artisan inventory:sync-warehouse-mapping-alert --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 -# 退款待确认四张镜像每天 06:00 在独立批容器完整跑完一轮,确保 10:00 前完成。 +# 退款待确认四张基础镜像每天 06:00 在独立批容器完整跑完一轮,确保 10:00 前完成; +# 当前退款状态在上面的分钟任务中优先增量刷新。 0 6 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-tables --daily-only --max-batches=0 --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 # 财务台账:每天 02:30 同步 AI 库动账并重算流水;10:30 生成前一晚店铺余额快照。 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 3bfd3ded..55c4c174 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -204,7 +204,7 @@ mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha" # 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。 cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF' CRON_TZ=Asia/Shanghai -* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 +* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables jiansun.dbo.jst_refund_view --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 # 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。 7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1 # 店铺候选平时仅读镜像变更;夜间全量核对历史名称。冷页面不扫描订单宽表。 @@ -234,7 +234,8 @@ CRON_TZ=Asia/Shanghai 15 10 * * * root /usr/bin/flock -n /run/caiwu-inventory-warehouse-alert.lock /usr/bin/docker exec -u www-data caiwu_app php artisan inventory:sync-warehouse-mapping-alert --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 -# 退款待确认四张镜像每天 06:00 在独立批容器完整跑完一轮,确保 10:00 前完成。 +# 退款待确认四张基础镜像每天 06:00 在独立批容器完整跑完一轮,确保 10:00 前完成; +# 当前退款状态在上面的分钟任务中优先增量刷新。 0 6 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-tables --daily-only --max-batches=0 --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 # 财务台账:每天 02:30 同步 AI 库动账并重算流水;10:30 生成前一晚店铺余额快照。 diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 61dbe293..da844ee3 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -78,7 +78,7 @@ 售后(退货退款)页面及其导出共同读取上述两套售后主单、各自商品明细与店铺镜像,按 `as_date` 筛选。原售后资料优先;退款清单按 `as_id`、或非空的 `(shop_id, outer_as_id)` 排除已存在的业务单。无商品明细的主单仍显示,商品数量、明细金额及成本留空;店铺平台无法唯一确认时标为“待确认”,不推断平台。两个来源的订单关联不得混用。订单号筛选按完整内部订单号、线上订单号或平台售后单号精确查询,避免长文本标识的全表扫描;店铺和商品仍支持包含搜索。分页先限制各来源的候选主单及行标识,再加载当前页明细;导出使用同一筛选及稳定游标。 -“退款待确认”复用同一套售后查询,并用 `dbo.jst_refund_view` 的当前 `as_id => status/shop_status` 覆盖报表副本里的旧状态后固定筛选 `status=WaitConfirm`、`shop_status=SUCCESS`;页面分别显示为“待确认”和“退款成功”。覆盖表只保留 2026-10-01 起的当前状态字段,按 `as_id` 建立索引并每日同步。筛选与导出始终保留该固定条件。 +“退款待确认”复用同一套售后查询,并用 `dbo.jst_refund_view` 的当前 `as_id => status/shop_status` 覆盖报表副本里的旧状态后固定筛选 `status=WaitConfirm`、`shop_status=SUCCESS`;页面分别显示为“待确认”和“退款成功”。覆盖表只保留 2026-10-01 起的当前状态字段,按 `as_id` 建立索引并按分钟增量同步,使用 1 小时重叠窗口和每周全量校准。筛选与导出始终保留该固定条件。 `jst_returnAndRefundItem` 没有更新时间,除按 `Id` 追加外,每 168 小时通过现有调度分批执行可续传全量校准,覆盖历史编号变化及删除;全量完成后才清理上一代残留。人工历史校准前须备份相关镜像、目录及同步状态,并持有生产上游同步互斥锁。退款清单两表继续按 `local_updated` 使用重叠增量,不新建重复镜像。 @@ -119,7 +119,7 @@ 供应商管理保留现有后台轮转刷新,同时每天北京时间 08:15 通过 `business-mirror:sync-supplier-management` 顺序完整校准供应商档案(含易签宝账号)、合同、联系人和打款资料。生产任务使用现有上游同步锁和 `www-data`,有其他同步占用时排队执行;每批沿用现有行数限制,直到四个数据集分别完成,失败记录在原镜像同步状态中。固定任务随正式发布安装,页面继续显示最近同步时间。 1. 首次全量只能通过 `business-mirror:sync-tables --full` 显式启动;失败或中断后使用同一命令继续。 -2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表使用 48 小时重叠窗口;没有可靠更新时间的表按稳定主键追加。`rpt.jst_sku` 由普通商品专用任务每分钟优先处理,每轮默认最多 5 批、每批 1000 行,不再参加通用数据集轮转。 +2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表默认使用 48 小时重叠窗口;退款当前状态使用 1 小时重叠窗口并每分钟刷新;没有可靠更新时间的表按稳定主键追加。`rpt.jst_sku` 由普通商品专用任务每分钟优先处理,每轮默认最多 5 批、每批 1000 行,不再参加通用数据集轮转。 3. 全量校准使用新的同步代次,只有全部完成后才删除镜像中本轮未出现的旧行,因此中途失败不会清空当前可用数据。 4. 普通商品在最近完整校准超过 168 小时后,于周日 02:00-08:00 低峰窗口启动全量校准;一旦启动,后续每分钟从稳定游标继续,直至完整结束。首次全量由 Codex 备份后自主执行,定时任务保持既有初始化开关。 5. 除专用数据集外,通用任务每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。 diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php index fb594b32..010ee162 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php @@ -411,7 +411,11 @@ class BusinessMirrorSynchronizer return [Carbon::parse((string) $state['window_started_at']), Carbon::parse((string) $state['window_ends_at'])]; } - $overlap = max((int) config('upstream_mirrors.table_sync.overlap_hours', 48), 1); + $overlap = max((int) $this->datasets->definitionValue( + $dataset->key(), + 'overlap_hours', + config('upstream_mirrors.table_sync.overlap_hours', 48), + ), 1); $last = Carbon::parse((string) ($state['last_succeeded_at'] ?? now()->subHours($overlap))); return [$last->subHours($overlap), now()->addSecond()]; diff --git a/modules/EcommerceFinance/Services/RefundPendingSyncStatus.php b/modules/EcommerceFinance/Services/RefundPendingSyncStatus.php index adab2a19..532c6fc1 100644 --- a/modules/EcommerceFinance/Services/RefundPendingSyncStatus.php +++ b/modules/EcommerceFinance/Services/RefundPendingSyncStatus.php @@ -131,8 +131,8 @@ class RefundPendingSyncStatus 'state' => $running ? 'syncing' : 'healthy', 'title' => $running ? '退款待确认正在同步' : '退款待确认同步正常', 'message' => $running - ? '每日同步正在执行,完成后会自动更新。' - : '每日 06:00 自动同步,10:00 前完成。', + ? '退款镜像正在同步,完成后会自动更新。' + : '基础退款镜像每日 06:00 同步,当前退款状态每分钟刷新。', 'last_succeeded_at' => $this->latest($successTimes), 'last_attempted_at' => $this->latest($attemptTimes), 'issues' => [], diff --git a/tests/Feature/Console/BusinessMirrorScheduleTest.php b/tests/Feature/Console/BusinessMirrorScheduleTest.php index 9c63ada9..a3ad6721 100644 --- a/tests/Feature/Console/BusinessMirrorScheduleTest.php +++ b/tests/Feature/Console/BusinessMirrorScheduleTest.php @@ -23,8 +23,15 @@ class BusinessMirrorScheduleTest extends TestCase 'operations_replica.rpt.jst_returnAndRefundItem', 'operations_replica.rpt.jst_qmcust_refundlist', 'operations_replica.rpt.jst_qmcust_refundlist_item', - 'jiansun.dbo.jst_refund_view', ], config('upstream_mirrors.table_sync.daily_datasets')); + $this->assertContains( + 'jiansun.dbo.jst_refund_view', + config('upstream_mirrors.table_sync.dedicated_datasets'), + ); + $this->assertSame( + 1, + config('upstream_mirrors.datasets')['jiansun.dbo.jst_refund_view']['overlap_hours'] ?? null, + ); } public function test_stale_source_does_not_block_healthy_datasets_and_reports_failure(): void diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index 975af505..1d05a916 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -59,6 +59,11 @@ class BusinessMirrorScheduleTest extends TestCase $minuteLine = $minuteLines[0] ?? null; $this->assertIsString($minuteLine, $script); $this->assertStringNotContainsString('business-mirror:sync-store-report', $minuteLine, $script); + $statusPosition = strpos($minuteLine, 'business-mirror:sync-tables jiansun.dbo.jst_refund_view --quiet'); + $catalogPosition = strpos($minuteLine, 'business-mirror:sync-product-catalog --quiet'); + $this->assertNotFalse($statusPosition, $script); + $this->assertNotFalse($catalogPosition, $script); + $this->assertLessThan($catalogPosition, $statusPosition, $script); $this->assertStringContainsString('docker exec -u www-data caiwu_app', $contents, $script); $this->assertStringContainsString('/run/caiwu-upstream-sync.lock', $contents, $script); $this->assertStringContainsString("CRON_TZ=Asia/Shanghai\n", $contents, $script); diff --git a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php index e6ba843e..2f7aaed8 100644 --- a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php @@ -252,6 +252,31 @@ class BusinessMirrorSynchronizerTest extends TestCase } } + public function test_incremental_sync_can_use_a_dataset_specific_overlap_window(): void + { + config([ + 'upstream_mirrors.datasets.test.dbo.records.overlap_hours' => 1, + 'app.timezone' => 'Asia/Shanghai', + ]); + \Illuminate\Support\Carbon::setTestNow('2026-08-15 12:30:00'); + + try { + $this->query->batches = [[ + ['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => '初始'], + ]]; + $this->synchronizer->sync('test.dbo.records', true, 10, 1); + + $this->query->calls = []; + $this->query->batches = [[]]; + $this->synchronizer->sync('test.dbo.records', false, 10, 1); + + $this->assertSame('2026-08-15T11:30:00.000', $this->query->calls[0]['bindings'][0]); + $this->assertSame('2026-08-15T12:30:01.000', $this->query->calls[0]['bindings'][1]); + } finally { + \Illuminate\Support\Carbon::setTestNow(); + } + } + public function test_key_first_incremental_read_preserves_filters_projection_and_resume_keys(): void { $this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '旧值']]]; diff --git a/tests/Unit/EcommerceFinance/RefundPendingSyncStatusTest.php b/tests/Unit/EcommerceFinance/RefundPendingSyncStatusTest.php index 63cc8619..bd67b84d 100644 --- a/tests/Unit/EcommerceFinance/RefundPendingSyncStatusTest.php +++ b/tests/Unit/EcommerceFinance/RefundPendingSyncStatusTest.php @@ -64,6 +64,21 @@ class RefundPendingSyncStatusTest extends TestCase $this->assertCount(5, $status['issues']); } + public function test_healthy_message_describes_realtime_status_refresh(): void + { + $states = $this->states(); + foreach ($states as &$state) { + $state['last_attempted_at'] = '2026-11-04 10:29:00'; + $state['last_succeeded_at'] = '2026-11-04 10:29:00'; + } + unset($state); + + $status = $this->service($states)->current(Carbon::parse('2026-11-04 10:30:00')); + + $this->assertSame('healthy', $status['state']); + $this->assertSame('基础退款镜像每日 06:00 同步,当前退款状态每分钟刷新。', $status['message']); + } + /** @param array> $states */ private function service(array $states): RefundPendingSyncStatus {