From e207075949ae2e7b95195ef30e3946add24ffc18 Mon Sep 17 00:00:00 2001 From: qiuxiang <394405259@qq.com> Date: Thu, 5 Nov 2026 17:10:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=AF=8F=E6=97=A5=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=81=9A=E6=B0=B4=E6=BD=AD=E6=A0=87=E7=AD=BE=E5=B9=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E7=A7=8D=E8=8F=9C=E9=87=91=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JstOrderLabelDailySynchronizer.php | 322 ++++++++++++++++++ .../StoreReceiptPlantingDailySynchronizer.php | 165 +++++++++ .../Commands/SyncJstOrderLabelsDaily.php | 84 +++++ .../SyncStoreReceiptPlantingDaily.php | 113 ++++++ deploy/activate-incremental-release.sh | 4 + deploy/activate-release.sh | 4 + deploy/finance-batch-imports.json | 1 + deploy/run-finance-batch.sh | 4 +- scripts/erp-daily-bills/README.md | 18 + .../Console/SyncJstOrderLabelsDailyTest.php | 162 +++++++++ ...reReceiptPlantingDailySynchronizerTest.php | 108 ++++++ .../Deployment/FinanceBatchIsolationTest.php | 54 +++ 12 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 app/Business/JstOrderLabelDailySynchronizer.php create mode 100644 app/Business/StoreReceiptPlantingDailySynchronizer.php create mode 100644 app/Console/Commands/SyncJstOrderLabelsDaily.php create mode 100644 app/Console/Commands/SyncStoreReceiptPlantingDaily.php create mode 100644 tests/Feature/Console/SyncJstOrderLabelsDailyTest.php create mode 100644 tests/Unit/Business/StoreReceiptPlantingDailySynchronizerTest.php diff --git a/app/Business/JstOrderLabelDailySynchronizer.php b/app/Business/JstOrderLabelDailySynchronizer.php new file mode 100644 index 00000000..017175ed --- /dev/null +++ b/app/Business/JstOrderLabelDailySynchronizer.php @@ -0,0 +1,322 @@ + + */ + public function sync( + string $from, + string $to, + int $companyId, + int $refreshDays = 7, + bool $dryRun = false, + ): array { + [$fromDate, $toDate] = $this->dateRange($from, $to); + if ($companyId <= 0) { + throw new InvalidArgumentException('公司 ID 无效。'); + } + if ($refreshDays < 1 || $refreshDays > 31) { + throw new InvalidArgumentException('刷新天数必须在 1 至 31 之间。'); + } + + $this->assertSourceReady(); + + $refreshStart = $fromDate->copy(); + $candidateRefreshStart = $toDate->copy()->subDays($refreshDays - 1); + if ($candidateRefreshStart->gt($refreshStart)) { + $refreshStart = $candidateRefreshStart; + } + + $historyEnd = $refreshStart->copy()->subDay(); + $historyCoverage = $historyEnd->lt($fromDate) + ? [] + : $this->coverage($fromDate, $historyEnd, $companyId); + $historyIssues = array_values(array_filter( + $historyCoverage, + static fn (array $row): bool => $row['status'] !== 'ok', + )); + + foreach ($historyIssues as $issue) { + if ($issue['status'] === 'source_empty') { + throw new RuntimeException(sprintf( + '历史数据检查未通过:源订单镜像在 %s 没有订单,已停止拉取新数据。', + $issue['date'], + )); + } + } + + $backfills = $this->contiguousRanges($historyIssues); + if ($dryRun) { + return [ + 'status' => $historyIssues === [] ? 'would_refresh' : 'would_backfill', + 'from' => $fromDate->toDateString(), + 'to' => $toDate->toDateString(), + 'checked_days' => (int) $fromDate->diffInDays($toDate) + 1, + 'history_issues' => $historyIssues, + 'backfills' => array_map(static fn (array $range): array => [ + 'from' => $range['from'], + 'to' => $range['to'], + ], $backfills), + 'refresh' => [ + 'from' => $refreshStart->toDateString(), + 'to' => $toDate->toDateString(), + ], + ]; + } + + $backfillResults = []; + foreach ($backfills as $range) { + $backfillResults[] = $this->importer->import( + $range['from'], + $range['to'], + $companyId, + true, + 'jst-order-label-daily-sync', + ); + } + + if ($historyIssues !== []) { + $remainingHistory = $this->coverage($fromDate, $historyEnd, $companyId); + $remainingIssues = array_values(array_filter( + $remainingHistory, + static fn (array $row): bool => $row['status'] !== 'ok', + )); + if ($remainingIssues !== []) { + throw new RuntimeException('历史缺口补导后仍未通过覆盖检查:' . $this->issueSummary($remainingIssues)); + } + } + + $refreshResult = $this->importer->import( + $refreshStart->toDateString(), + $toDate->toDateString(), + $companyId, + true, + 'jst-order-label-daily-sync', + ); + + $finalCoverage = $this->coverage($fromDate, $toDate, $companyId); + $finalIssues = array_values(array_filter( + $finalCoverage, + static fn (array $row): bool => $row['status'] !== 'ok', + )); + if ($finalIssues !== []) { + throw new RuntimeException('同步后覆盖检查未通过:' . $this->issueSummary($finalIssues)); + } + + return [ + 'status' => 'ok', + 'from' => $fromDate->toDateString(), + 'to' => $toDate->toDateString(), + 'checked_days' => count($finalCoverage), + 'backfilled_ranges' => array_map(static fn (array $range): array => [ + 'from' => $range['from'], + 'to' => $range['to'], + ], $backfills), + 'backfill_results' => $backfillResults, + 'refresh' => $refreshResult, + ]; + } + + /** + * 只读确认指定日期都已覆盖;任何缺日或订单数不一致都会阻止下游重算。 + * + * @return list + */ + public function assertCovered(string $from, string $to, int $companyId): array + { + [$fromDate, $toDate] = $this->dateRange($from, $to); + if ($companyId <= 0) { + throw new InvalidArgumentException('公司 ID 无效。'); + } + + $this->assertSourceReady(); + $coverage = $this->coverage($fromDate, $toDate, $companyId); + $issues = array_values(array_filter( + $coverage, + static fn (array $row): bool => $row['status'] !== 'ok', + )); + if ($issues !== []) { + throw new RuntimeException('历史数据检查未通过:' . $this->issueSummary($issues)); + } + + return $coverage; + } + + /** + * 逐日比较源订单号数与标签明细订单号数。 + * + * 标签拆行后同一订单会出现多行,因此不能用总行数直接比较订单完整性。 + * + * @return list + */ + public function coverage(Carbon $from, Carbon $to, int $companyId): array + { + $start = $from->copy()->startOfDay()->toDateTimeString(); + $end = $to->copy()->addDay()->startOfDay()->toDateTimeString(); + $mirror = $this->mirror(); + $target = $this->databases->connection(); + + $sourceRows = $mirror->table('ops_jst_orders') + ->where('order_date', '>=', $start) + ->where('order_date', '<', $end) + ->selectRaw('DATE(order_date) AS day, COUNT(DISTINCT so_id) AS order_count') + ->groupByRaw('DATE(order_date)') + ->get() + ->keyBy('day'); + + $targetRows = $target->table('jst_order_labels') + ->where('company_id', $companyId) + ->where('ordered_at', '>=', $start) + ->where('ordered_at', '<', $end) + ->selectRaw('DATE(ordered_at) AS day, COUNT(*) AS row_count, COUNT(DISTINCT order_no) AS order_count') + ->groupByRaw('DATE(ordered_at)') + ->get() + ->keyBy('day'); + + $coverage = []; + for ($day = $from->copy()->startOfDay(); $day->lte($to); $day->addDay()) { + $date = $day->toDateString(); + $sourceOrders = (int) ($sourceRows[$date]->order_count ?? 0); + $targetOrderCount = (int) ($targetRows[$date]->order_count ?? 0); + $targetRowsCount = (int) ($targetRows[$date]->row_count ?? 0); + $status = match (true) { + $sourceOrders === 0 => 'source_empty', + $targetOrderCount === 0 => 'missing', + $targetOrderCount < $sourceOrders => 'incomplete', + $targetOrderCount > $sourceOrders => 'extra_orders', + default => 'ok', + }; + + $coverage[] = [ + 'date' => $date, + 'source_orders' => $sourceOrders, + 'target_orders' => $targetOrderCount, + 'target_rows' => $targetRowsCount, + 'status' => $status, + ]; + } + + return $coverage; + } + + private function assertSourceReady(): void + { + $mirror = $this->mirror(); + $schema = $mirror->getSchemaBuilder(); + if (! $schema->hasTable('ops_jst_orders')) { + throw new RuntimeException('聚水潭订单镜像尚未建立(ops_jst_orders)。'); + } + if (! $schema->hasTable('business_mirror_table_states')) { + throw new RuntimeException('聚水潭订单镜像同步状态表尚未建立。'); + } + + $state = $mirror->table('business_mirror_table_states') + ->where('dataset_key', self::SOURCE_DATASET) + ->first(['status', 'initial_completed_at']); + if ($state === null || $state->initial_completed_at === null) { + throw new RuntimeException('聚水潭订单镜像尚未完成首次同步。'); + } + if ((string) $state->status !== 'succeeded') { + throw new RuntimeException('聚水潭订单镜像最近同步未成功,已停止标签明细同步。'); + } + } + + private function mirror(): \Illuminate\Database\ConnectionInterface + { + return $this->databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @param list $issues + * @return list + */ + private function contiguousRanges(array $issues): array + { + usort($issues, static fn (array $left, array $right): int => $left['date'] <=> $right['date']); + $ranges = []; + $start = null; + $previous = null; + + foreach ($issues as $issue) { + $day = Carbon::createFromFormat('!Y-m-d', $issue['date'], 'Asia/Shanghai'); + if ($start === null) { + $start = $day; + $previous = $day; + continue; + } + + if (! $day->isSameDay($previous->copy()->addDay())) { + $ranges[] = ['from' => $start->toDateString(), 'to' => $previous->toDateString()]; + $start = $day; + } + $previous = $day; + } + + if ($start !== null && $previous !== null) { + $ranges[] = ['from' => $start->toDateString(), 'to' => $previous->toDateString()]; + } + + return $ranges; + } + + /** @param list $issues */ + private function issueSummary(array $issues): string + { + return implode(',', array_map( + static fn (array $issue): string => $issue['date'] . '=' . $issue['status'], + $issues, + )); + } + + /** @return array{0:Carbon,1:Carbon} */ + private function dateRange(string $from, string $to): array + { + $fromDate = $this->date($from); + $toDate = $this->date($to); + if ($fromDate === null || $toDate === null || $fromDate->gt($toDate)) { + throw new InvalidArgumentException('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。'); + } + + return [$fromDate, $toDate]; + } + + private function date(string $value): ?Carbon + { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + return null; + } + + try { + $date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai'); + } catch (Throwable) { + return null; + } + + return $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/app/Business/StoreReceiptPlantingDailySynchronizer.php b/app/Business/StoreReceiptPlantingDailySynchronizer.php new file mode 100644 index 00000000..479635c9 --- /dev/null +++ b/app/Business/StoreReceiptPlantingDailySynchronizer.php @@ -0,0 +1,165 @@ + $months + * @return array + */ + public function sync( + string $from, + string $to, + int $companyId, + array $months = [], + bool $dryRun = false, + ): array { + [$fromDate, $toDate] = $this->dateRange($from, $to); + if ($companyId <= 0) { + throw new InvalidArgumentException('公司 ID 无效。'); + } + + $coverage = $this->labelCoverage->assertCovered( + $fromDate->toDateString(), + $toDate->toDateString(), + $companyId, + ); + $months = $this->months($months, $toDate); + $this->assertWorkOrderSourceReady(); + + if ($dryRun) { + return [ + 'status' => 'ready', + 'from' => $fromDate->toDateString(), + 'to' => $toDate->toDateString(), + 'checked_days' => count($coverage), + 'months' => $months, + 'dry_run' => true, + ]; + } + + $plantingSync = []; + foreach ($months as $month) { + $plantingSync[$month] = $this->plantingWorkOrders->sync( + $month . '-01', + $this->monthEnd($month), + $companyId, + ); + } + + return [ + 'status' => 'ready_to_refresh', + 'from' => $fromDate->toDateString(), + 'to' => $toDate->toDateString(), + 'checked_days' => count($coverage), + 'months' => $months, + 'planting_sync' => $plantingSync, + 'dry_run' => false, + ]; + } + + /** @return list */ + private function months(array $requested, Carbon $through): array + { + if ($requested === []) { + return [ + $through->copy()->subMonthNoOverflow()->format('Y-m'), + $through->format('Y-m'), + ]; + } + + $months = []; + foreach ($requested as $month) { + $month = trim((string) $month); + $normalized = null; + if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) { + try { + $normalized = Carbon::createFromFormat('!Y-m', $month, 'Asia/Shanghai')->format('Y-m'); + } catch (Throwable) { + $normalized = null; + } + } + if ($normalized !== $month) { + throw new InvalidArgumentException('月份格式无效,应为 YYYY-MM:' . $month); + } + $months[$month] = true; + } + $months = array_keys($months); + sort($months); + + return $months; + } + + private function assertWorkOrderSourceReady(): void + { + $mirror = $this->databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + $schema = $mirror->getSchemaBuilder(); + if (! $schema->hasTable('business_mirror_table_states')) { + throw new RuntimeException('聚水潭协同工单镜像同步状态表尚未建立。'); + } + + $state = $mirror->table('business_mirror_table_states') + ->where('dataset_key', self::WORK_ORDER_DATASET) + ->first(['status', 'initial_completed_at']); + if ($state === null || $state->initial_completed_at === null) { + throw new RuntimeException('聚水潭协同工单镜像尚未完成首次同步。'); + } + if ((string) $state->status !== 'succeeded') { + throw new RuntimeException('聚水潭协同工单镜像最近同步未成功。'); + } + } + + /** @return array{0:Carbon,1:Carbon} */ + private function dateRange(string $from, string $to): array + { + $fromDate = $this->date($from); + $toDate = $this->date($to); + if ($fromDate === null || $toDate === null || $fromDate->gt($toDate)) { + throw new InvalidArgumentException('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。'); + } + + return [$fromDate, $toDate]; + } + + private function date(string $value): ?Carbon + { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + return null; + } + + try { + $date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai'); + } catch (Throwable) { + return null; + } + + return $date->format('Y-m-d') === $value ? $date : null; + } + + private function monthEnd(string $month): string + { + return Carbon::createFromFormat('!Y-m', $month, 'Asia/Shanghai') + ->endOfMonth() + ->toDateString(); + } +} diff --git a/app/Console/Commands/SyncJstOrderLabelsDaily.php b/app/Console/Commands/SyncJstOrderLabelsDaily.php new file mode 100644 index 00000000..551c626f --- /dev/null +++ b/app/Console/Commands/SyncJstOrderLabelsDaily.php @@ -0,0 +1,84 @@ +option('from')); + $to = trim((string) $this->option('to')); + $to = $to !== '' ? $to : Carbon::now('Asia/Shanghai')->subDay()->toDateString(); + + try { + $result = $synchronizer->sync( + $from, + $to, + (int) $this->option('company'), + (int) $this->option('refresh-days'), + (bool) $this->option('dry-run'), + ); + } catch (Throwable $exception) { + if ($this->option('json')) { + $this->line(json_encode([ + 'status' => 'failed', + 'from' => $from, + 'to' => $to, + 'error' => $exception->getMessage(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } else { + $this->error($exception->getMessage()); + } + + return self::FAILURE; + } + + if ($this->option('json')) { + $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return self::SUCCESS; + } + + $this->info(sprintf( + '聚水潭订单标签明细同步完成:%s ~ %s,覆盖 %d 天。', + $result['from'], + $result['to'], + (int) ($result['checked_days'] ?? 0), + )); + foreach ($result['backfill_results'] ?? [] as $backfill) { + $this->line(sprintf( + ' 补导 %s ~ %s:%d 单,%d 行。', + $backfill['from'], + $backfill['to'], + (int) $backfill['orders'], + (int) $backfill['rows'], + )); + } + $refresh = $result['refresh'] ?? null; + if (is_array($refresh)) { + $this->line(sprintf( + ' 刷新 %s ~ %s:%d 单,%d 行。', + $refresh['from'], + $refresh['to'], + (int) $refresh['orders'], + (int) $refresh['rows'], + )); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncStoreReceiptPlantingDaily.php b/app/Console/Commands/SyncStoreReceiptPlantingDaily.php new file mode 100644 index 00000000..ef34997d --- /dev/null +++ b/app/Console/Commands/SyncStoreReceiptPlantingDaily.php @@ -0,0 +1,113 @@ +option('from')); + $to = trim((string) $this->option('to')); + $to = $to !== '' ? $to : Carbon::now('Asia/Shanghai')->subDay()->toDateString(); + $companyId = (int) $this->option('company'); + $months = array_values(array_filter( + array_map('strval', (array) $this->option('month')), + static fn (string $month): bool => trim($month) !== '', + )); + + try { + $result = $synchronizer->sync( + $from, + $to, + $companyId, + $months, + (bool) $this->option('dry-run'), + ); + } catch (Throwable $exception) { + $this->outputResult([ + 'status' => 'failed', + 'from' => $from, + 'to' => $to, + 'error' => $exception->getMessage(), + ]); + + return self::FAILURE; + } + + if ((bool) $this->option('dry-run')) { + $this->outputResult($result); + return self::SUCCESS; + } + + $results = []; + foreach ($result['months'] as $month) { + $exitCode = $this->call('store-receipt:order-match:sync', [ + 'month' => $month, + '--company' => $companyId, + '--planting-only' => true, + ]); + $results[$month] = ['exit_code' => $exitCode]; + if ($exitCode !== self::SUCCESS) { + $this->outputResult([ + 'status' => 'failed', + 'from' => $from, + 'to' => $to, + 'months' => $result['months'], + 'results' => $results, + 'error' => $month . ' 种菜金额重算失败。', + ]); + + return self::FAILURE; + } + } + + $result['status'] = 'ok'; + $result['results'] = $results; + $this->outputResult($result); + + return self::SUCCESS; + } + + private function outputResult(array $result): void + { + if ((bool) $this->option('json')) { + $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return; + } + + if (($result['status'] ?? '') === 'failed') { + $this->error((string) ($result['error'] ?? '种菜金额同步失败。')); + return; + } + + $this->info(sprintf( + '业绩明细种菜金额已刷新:核查 %s ~ %s,月份 %s。', + $result['from'], + $result['to'], + implode('、', $result['months']), + )); + foreach ($result['planting_sync'] ?? [] as $month => $sync) { + $this->line(sprintf( + ' %s 种菜报销明细:%d 单,写入 %d 行。', + $month, + (int) ($sync['orders'] ?? 0), + (int) ($sync['inserted'] ?? 0), + )); + } + } +} diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 54d4c195..b0211444 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -215,6 +215,10 @@ CRON_TZ=Asia/Shanghai # 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。 3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/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 business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 +# 聚水潭订单标签明细:先核对 2026-10-01 起逐日覆盖,补历史缺口,再刷新最近 7 天。 +10 6 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh jst-order-labels:sync-daily --from=2026-10-01 --company=1 --quiet >/dev/null 2>&1 +# 业绩明细种菜金额:每天 09:30 核查标签覆盖,同步本月/上月种菜报销并重算种菜金额。 +30 9 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:planting-daily --from=2026-10-01 --company=1 --quiet >/dev/null 2>&1 # 商品月加权与成本:先算月加权单价,再同步商品成本,避免成本先于价格生成。 0 18 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh finance:monthly-weighted-product-cost:auto >/dev/null 2>&1 # 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index ebdcd1e9..3bfd3ded 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -219,6 +219,10 @@ CRON_TZ=Asia/Shanghai # 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。 3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/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 business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 +# 聚水潭订单标签明细:先核对 2026-10-01 起逐日覆盖,补历史缺口,再刷新最近 7 天。 +10 6 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh jst-order-labels:sync-daily --from=2026-10-01 --company=1 --quiet >/dev/null 2>&1 +# 业绩明细种菜金额:每天 09:30 核查标签覆盖,同步本月/上月种菜报销并重算种菜金额。 +30 9 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:planting-daily --from=2026-10-01 --company=1 --quiet >/dev/null 2>&1 # 商品月加权与成本:先算月加权单价,再同步商品成本,避免成本先于价格生成。 0 18 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh finance:monthly-weighted-product-cost:auto >/dev/null 2>&1 # 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 diff --git a/deploy/finance-batch-imports.json b/deploy/finance-batch-imports.json index ced46090..9a1873a9 100644 --- a/deploy/finance-batch-imports.json +++ b/deploy/finance-batch-imports.json @@ -21,6 +21,7 @@ "business-mirror:import-jd-wallet", "jst-order-labels:import-planting-rules", "jst-order-labels:import", + "jst-order-labels:sync-daily", "business:import-jushuitan-shop-balances", "pinduoduo-bills:import-authoritative", "planting-details:sync-work-orders", diff --git a/deploy/run-finance-batch.sh b/deploy/run-finance-batch.sh index a3d887fa..fba6ce81 100644 --- a/deploy/run-finance-batch.sh +++ b/deploy/run-finance-batch.sh @@ -8,8 +8,8 @@ is_import=0 CAIWU_BATCH_CALLER_DIR="$PWD" export CAIWU_BATCH_CALLER_DIR case "${1:-}" in - *:import|*:import-*|*:import:*|product:monthly-weighted-average:base|planting-details:sync-work-orders) is_import=1 ;; - jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:sync-tables|business-mirror:sync-ding-process-details|business-mirror:process-ding-refresh-requests|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate|product:monthly-weighted-average:auto|product:monthly-weighted-average:compute|product:monthly-weighted-average:report|finance:monthly-weighted-product-cost:auto) ;; + *:import|*:import-*|*:import:*|product:monthly-weighted-average:base|planting-details:sync-work-orders|jst-order-labels:sync-daily) is_import=1 ;; + jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:planting-daily|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:sync-tables|business-mirror:sync-ding-process-details|business-mirror:process-ding-refresh-requests|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate|product:monthly-weighted-average:auto|product:monthly-weighted-average:compute|product:monthly-weighted-average:report|finance:monthly-weighted-product-cost:auto) ;; *) echo 'Unsupported finance batch command' >&2; exit 64 ;; esac case "${1:-}" in diff --git a/scripts/erp-daily-bills/README.md b/scripts/erp-daily-bills/README.md index d96b99b3..7a328877 100644 --- a/scripts/erp-daily-bills/README.md +++ b/scripts/erp-daily-bills/README.md @@ -45,6 +45,24 @@ docker exec -u www-data caiwu_app php artisan business-mirror:verify-erp-daily-c 命令会逐个月份检查期间起止范围、有效版本行数和登记行数;缺月、只导到月中或行数不一致都会返回失败。 +## 聚水潭订单标签明细 + +聚水潭订单标签明细复用已同步的 `ops_jst_orders`,不经过浏览器导出。生产宿主机每天 06:10 在订单镜像 05:00 同步后执行: + +```bash +sh /opt/caiwuxitong/run-finance-batch.sh jst-order-labels:sync-daily --from=2026-10-01 --company=1 +``` + +任务会先逐日比较源订单与标签明细的订单覆盖。历史缺口按连续日期补导;历史检查未通过时不继续拉取新数据。通过后替换刷新最近 7 天,并再次复核整段覆盖。手动预览可加 `--dry-run --json`。 + +业绩明细的种菜金额每天 09:30 单独刷新: + +```bash +sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:planting-daily --from=2026-10-01 --company=1 +``` + +任务先确认标签明细覆盖完整,再同步当前月和上月的种菜报销工单,最后对这两个月执行 `store-receipt:order-match:sync --planting-only`。种菜订单号取“种菜报销明细订单号”和“聚水潭是否种菜订单=是”的并集,命中订单的种菜金额取该业绩行收入金额。 + ## 组成 | 文件 | 作用 | diff --git a/tests/Feature/Console/SyncJstOrderLabelsDailyTest.php b/tests/Feature/Console/SyncJstOrderLabelsDailyTest.php new file mode 100644 index 00000000..dafb946f --- /dev/null +++ b/tests/Feature/Console/SyncJstOrderLabelsDailyTest.php @@ -0,0 +1,162 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]]); + DB::purge('business_mirror'); + + Schema::connection('business_mirror')->create('business_mirror_table_states', function ($table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + }); + Schema::connection('business_mirror')->create('ops_jst_orders', function ($table): void { + $table->integer('Id')->primary(); + $table->bigInteger('o_id')->nullable(); + $table->string('so_id')->nullable(); + $table->string('shop_name')->nullable(); + $table->dateTime('order_date')->nullable(); + $table->text('labels')->nullable(); + $table->text('remark')->nullable(); + }); + Schema::connection('business_mirror')->create('ops_jst_order_item', function ($table): void { + $table->integer('Id')->primary(); + $table->bigInteger('o_id')->nullable(); + $table->string('sku_id')->nullable(); + $table->string('item_status')->nullable(); + }); + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => 'operations_replica.rpt.jst_Orders', + 'status' => 'succeeded', + 'initial_completed_at' => '2026-10-01 00:00:00', + 'last_succeeded_at' => '2026-10-04 05:00:00', + ]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + parent::tearDown(); + } + + public function test_backfills_missing_history_then_refreshes_recent_days(): void + { + $this->orders([ + ['o_id' => 1, 'so_id' => 'A', 'order_date' => '2026-10-01 10:00:00', 'labels' => '标签甲,标签乙'], + ['o_id' => 2, 'so_id' => 'B', 'order_date' => '2026-10-02 10:00:00', 'labels' => ''], + ['o_id' => 3, 'so_id' => 'C', 'order_date' => '2026-10-03 10:00:00', 'labels' => '标签丙'], + ]); + + $this->artisan('jst-order-labels:sync-daily', [ + '--from' => '2026-10-01', + '--to' => '2026-10-03', + '--company' => $this->company->id, + '--refresh-days' => 1, + '--json' => true, + ])->assertSuccessful(); + + $rows = JstOrderLabel::query()->where('company_id', $this->company->id)->orderBy('id')->get(); + $this->assertCount(4, $rows); + $this->assertSame(['标签甲', '标签乙', '(无标签)', '标签丙'], $rows->pluck('label')->all()); + $this->assertSame(2, $rows->where('order_no', 'A')->count()); + } + + public function test_does_not_pull_new_data_when_history_source_is_missing(): void + { + $this->orders([ + ['o_id' => 3, 'so_id' => 'C', 'order_date' => '2026-10-03 10:00:00', 'labels' => '标签丙'], + ]); + + $this->artisan('jst-order-labels:sync-daily', [ + '--from' => '2026-10-01', + '--to' => '2026-10-03', + '--company' => $this->company->id, + '--refresh-days' => 1, + ])->assertFailed(); + + $this->assertSame(0, JstOrderLabel::query()->where('company_id', $this->company->id)->count()); + } + + public function test_second_run_skips_complete_history_and_only_refreshes_latest_day(): void + { + $this->orders([ + ['o_id' => 1, 'so_id' => 'A', 'order_date' => '2026-10-01 10:00:00', 'labels' => '标签甲'], + ['o_id' => 2, 'so_id' => 'B', 'order_date' => '2026-10-02 10:00:00', 'labels' => '标签乙'], + ]); + + app(JstOrderLabelImporter::class)->import( + '2026-10-01', + '2026-10-01', + $this->company->id, + true, + 'test', + ); + $oldCreatedAt = JstOrderLabel::query()->where('order_no', 'A')->value('created_at')?->toDateTimeString(); + + $this->artisan('jst-order-labels:sync-daily', [ + '--from' => '2026-10-01', + '--to' => '2026-10-02', + '--company' => $this->company->id, + '--refresh-days' => 1, + '--json' => true, + ])->assertSuccessful(); + + $this->assertSame( + $oldCreatedAt, + JstOrderLabel::query()->where('order_no', 'A')->value('created_at')?->toDateTimeString(), + ); + $this->assertSame(1, JstOrderLabel::query()->where('order_no', 'B')->count()); + } + + public function test_refuses_to_run_when_source_mirror_is_not_ready(): void + { + DB::connection('business_mirror')->table('business_mirror_table_states') + ->where('dataset_key', 'operations_replica.rpt.jst_Orders') + ->update(['status' => 'failed']); + $this->orders([ + ['o_id' => 1, 'so_id' => 'A', 'order_date' => '2026-10-01 10:00:00', 'labels' => '标签甲'], + ]); + + $this->artisan('jst-order-labels:sync-daily', [ + '--from' => '2026-10-01', + '--to' => '2026-10-01', + '--company' => $this->company->id, + ])->assertFailed(); + + $this->assertSame(0, JstOrderLabel::query()->where('company_id', $this->company->id)->count()); + } + + private function orders(array $rows): void + { + foreach ($rows as $index => $row) { + DB::connection('business_mirror')->table('ops_jst_orders')->insert([ + 'Id' => $index + 1, + 'o_id' => $row['o_id'], + 'so_id' => $row['so_id'], + 'shop_name' => '测试店铺', + 'order_date' => $row['order_date'], + 'labels' => $row['labels'], + 'remark' => '', + ]); + } + } +} diff --git a/tests/Unit/Business/StoreReceiptPlantingDailySynchronizerTest.php b/tests/Unit/Business/StoreReceiptPlantingDailySynchronizerTest.php new file mode 100644 index 00000000..b044aea6 --- /dev/null +++ b/tests/Unit/Business/StoreReceiptPlantingDailySynchronizerTest.php @@ -0,0 +1,108 @@ + [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]]); + DB::purge('business_mirror'); + Schema::connection('business_mirror')->create('business_mirror_table_states', function ($table): void { + $table->string('dataset_key')->primary(); + $table->string('status')->nullable(); + $table->dateTime('initial_completed_at')->nullable(); + }); + DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ + 'dataset_key' => 'jiansun.dbo.jst_workOrderInfo', + 'status' => 'succeeded', + 'initial_completed_at' => '2026-10-01 00:00:00', + ]); + } + + public function test_checks_label_coverage_then_syncs_requested_months(): void + { + $coverage = Mockery::mock(JstOrderLabelDailySynchronizer::class); + $coverage->shouldReceive('assertCovered') + ->once() + ->with('2026-10-01', '2026-10-31', 1) + ->andReturn(array_fill(0, 31, ['status' => 'ok'])); + $workOrders = Mockery::mock(PlantingWorkOrderSynchronizer::class); + $workOrders->shouldReceive('sync') + ->once() + ->with('2026-10-01', '2026-10-31', 1) + ->andReturn(['orders' => 10, 'inserted' => 10]); + + $synchronizer = new StoreReceiptPlantingDailySynchronizer( + app(DatabaseManager::class), + $coverage, + $workOrders, + ); + + $result = $synchronizer->sync('2026-10-01', '2026-10-31', 1, ['2026-10']); + + $this->assertSame('ready_to_refresh', $result['status']); + $this->assertSame(['2026-10'], $result['months']); + $this->assertSame(31, $result['checked_days']); + $this->assertSame(10, $result['planting_sync']['2026-10']['orders']); + } + + public function test_defaults_to_current_and_previous_month(): void + { + $coverage = Mockery::mock(JstOrderLabelDailySynchronizer::class); + $coverage->shouldReceive('assertCovered')->once()->andReturn([['status' => 'ok']]); + $workOrders = Mockery::mock(PlantingWorkOrderSynchronizer::class); + $workOrders->shouldReceive('sync') + ->once() + ->with('2026-10-01', '2026-10-31', 1) + ->andReturn([]); + $workOrders->shouldReceive('sync') + ->once() + ->with('2026-11-01', '2026-11-30', 1) + ->andReturn([]); + + $synchronizer = new StoreReceiptPlantingDailySynchronizer( + app(DatabaseManager::class), + $coverage, + $workOrders, + ); + + $result = $synchronizer->sync('2026-10-01', '2026-11-04', 1, ['2026-11', '2026-10']); + + $this->assertSame(['2026-10', '2026-11'], $result['months']); + } + + public function test_blocks_recalculation_when_label_coverage_is_not_ready(): void + { + $coverage = Mockery::mock(JstOrderLabelDailySynchronizer::class); + $coverage->shouldReceive('assertCovered') + ->once() + ->andThrow(new \RuntimeException('历史数据检查未通过')); + $workOrders = Mockery::mock(PlantingWorkOrderSynchronizer::class); + $workOrders->shouldNotReceive('sync'); + + $synchronizer = new StoreReceiptPlantingDailySynchronizer( + app(DatabaseManager::class), + $coverage, + $workOrders, + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('历史数据检查未通过'); + $synchronizer->sync('2026-10-01', '2026-10-31', 1, ['2026-10']); + } +} diff --git a/tests/Unit/Deployment/FinanceBatchIsolationTest.php b/tests/Unit/Deployment/FinanceBatchIsolationTest.php index 00110056..8b3be896 100644 --- a/tests/Unit/Deployment/FinanceBatchIsolationTest.php +++ b/tests/Unit/Deployment/FinanceBatchIsolationTest.php @@ -138,6 +138,60 @@ class FinanceBatchIsolationTest extends TestCase } } + public function test_jst_label_daily_sync_uses_the_isolated_import_entry(): void + { + $directory = sys_get_temp_dir() . '/caiwu-jst-label-import-' . bin2hex(random_bytes(6)); + mkdir($directory); + file_put_contents($directory . '/run-finance-import.py', ''); + file_put_contents( + $directory . '/python3', + '#!/bin/sh' . "\n" . 'printf "%s\n" "$@" > "$CAIWU_CAPTURE"' . "\n", + ); + chmod($directory . '/python3', 0700); + + try { + $process = $this->runBatch($directory, [ + 'jst-order-labels:sync-daily', + '--from=2026-10-01', + ]); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $arguments = file($directory . '/called', FILE_IGNORE_NEW_LINES); + $this->assertContains($directory . '/run-finance-import.py', $arguments); + $this->assertContains('jst-order-labels:sync-daily', $arguments); + } finally { + foreach (glob($directory . '/*') as $path) { + unlink($path); + } + rmdir($directory); + } + } + + public function test_planting_daily_uses_the_isolated_batch_profile(): void + { + $directory = sys_get_temp_dir() . '/caiwu-planting-daily-' . bin2hex(random_bytes(6)); + mkdir($directory); + try { + $arguments = [ + 'store-receipt:planting-daily', + '--from=2026-10-01', + '--company=1', + ]; + $process = $this->runBatch($directory, $arguments); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $called = file($directory . '/called', FILE_IGNORE_NEW_LINES); + $this->assertContains('batch', $called); + $this->assertContains('--no-deps', $called); + $this->assertSame($arguments, array_slice($called, -count($arguments))); + } finally { + foreach (glob($directory . '/*') as $path) { + unlink($path); + } + rmdir($directory); + } + } + public function test_business_mirror_history_backfill_runs_in_the_isolated_batch_profile(): void { $directory = sys_get_temp_dir() . '/caiwu-mirror-batch-' . bin2hex(random_bytes(6)); -- 2.47.2