feat: 每日同步聚水潭标签并刷新种菜金额 #896

Merged
qiuxiang merged 1 commits from feature/qiuxiang-jst-order-label-daily-sync into main 2026-11-05 17:07:59 +08:00
12 changed files with 1037 additions and 2 deletions

View File

@ -0,0 +1,322 @@
<?php
namespace App\Business;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/**
* 聚水潭订单标签明细的每日同步。
*
* 先核对历史日期是否逐日覆盖,再补齐缺口,最后替换刷新最近数日以吸收上游迟到修改。
*/
class JstOrderLabelDailySynchronizer
{
public const SOURCE_DATASET = 'operations_replica.rpt.jst_Orders';
public function __construct(
private readonly DatabaseManager $databases,
private readonly JstOrderLabelImporter $importer,
) {}
/**
* @return array<string,mixed>
*/
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<array{date:string,source_orders:int,target_orders:int,target_rows:int,status:string}>
*/
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<array{
* date:string,source_orders:int,target_orders:int,target_rows:int,status:string
* }>
*/
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<array{date:string}> $issues
* @return list<array{from:string,to:string}>
*/
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<array{date:string,status:string}> $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;
}
}

View File

@ -0,0 +1,165 @@
<?php
namespace App\Business;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/**
* 每日编排:先核对标签覆盖,再同步种菜报销工单,最后重算业绩明细种菜金额。
*/
class StoreReceiptPlantingDailySynchronizer
{
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
public function __construct(
private readonly DatabaseManager $databases,
private readonly JstOrderLabelDailySynchronizer $labelCoverage,
private readonly PlantingWorkOrderSynchronizer $plantingWorkOrders,
) {}
/**
* @param list<string> $months
* @return array<string,mixed>
*/
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<string> */
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();
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Console\Commands;
use App\Business\JstOrderLabelDailySynchronizer;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Throwable;
class SyncJstOrderLabelsDaily extends Command
{
protected $signature = 'jst-order-labels:sync-daily
{--from=2026-10-01 : 覆盖核查起始日期 YYYY-MM-DD}
{--to= : 截止日期 YYYY-MM-DD,默认昨日}
{--company=1 : 公司 ID}
{--refresh-days=7 : 每日替换刷新最近天数}
{--dry-run : 只检查并输出计划,不写库}
{--json : 输出 JSON}';
protected $description = '先核对聚水潭订单标签历史覆盖,补齐缺口后刷新最近数据';
public function handle(JstOrderLabelDailySynchronizer $synchronizer): int
{
$from = trim((string) $this->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;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreReceiptPlantingDailySynchronizer;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Throwable;
class SyncStoreReceiptPlantingDaily extends Command
{
protected $signature = 'store-receipt:planting-daily
{--from=2026-10-01 : 标签明细覆盖核查起始日期 YYYY-MM-DD}
{--to= : 覆盖核查截止日期,默认昨日}
{--company=1 : 公司 ID}
{--month=* : 重算月份 YYYY-MM,可重复;默认当前月和上月}
{--dry-run : 只核查,不写库}
{--json : 输出 JSON}';
protected $description = '核查标签覆盖并刷新业绩明细的种菜金额和标签';
public function handle(StoreReceiptPlantingDailySynchronizer $synchronizer): int
{
$from = trim((string) $this->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),
));
}
}
}

View File

@ -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 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。

View File

@ -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 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。

View File

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

View File

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

View File

@ -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`。种菜订单号取“种菜报销明细订单号”和“聚水潭是否种菜订单=是”的并集,命中订单的种菜金额取该业绩行收入金额。
## 组成
| 文件 | 作用 |

View File

@ -0,0 +1,162 @@
<?php
namespace Tests\Feature\Console;
use App\Business\JstOrderLabelImporter;
use App\Models\Business\JstOrderLabel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\Feature\FeatureTestCase;
class SyncJstOrderLabelsDailyTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-10-04 10:00:00', 'Asia/Shanghai'));
config(['database.connections.business_mirror' => [
'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' => '',
]);
}
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace Tests\Unit\Business;
use App\Business\JstOrderLabelDailySynchronizer;
use App\Business\PlantingWorkOrderSynchronizer;
use App\Business\StoreReceiptPlantingDailySynchronizer;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Tests\TestCase;
class StoreReceiptPlantingDailySynchronizerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.connections.business_mirror' => [
'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']);
}
}

View File

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