> /dev/null 2>&1 */ class TcmBackfillPrescriptionOrderSignTime extends Command { /** * 默认不按履约状态过滤:只要订单有运单号(tracking_number)即视为已发货,纳入回填。 * 按 fulfillment_status 过滤会漏掉「物流已送达、但业务状态滞后(如进行中)」的单子。 * 仍可用 --fulfillment-status 显式收窄。 */ private const DEFAULT_FULFILLMENT_STATUSES = []; protected function configure(): void { $this->setName('tcm:backfill-sign-time') ->setDescription('批量回填处方业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP)') ->addOption('dry-run', null, Option::VALUE_NONE, '只列出待处理订单,不发起查询/不落库') ->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多补查(HTTP)订单数', 300) ->addOption('order-id', null, Option::VALUE_OPTIONAL, '仅处理指定 prescription_order.id', null) ->addOption('min-id', null, Option::VALUE_OPTIONAL, '仅 id>=该值', null) ->addOption('max-id', null, Option::VALUE_OPTIONAL, '仅 id<=该值', null) ->addOption('days', null, Option::VALUE_OPTIONAL, '仅处理近 N 天创建的订单(0=不限)', 0) ->addOption( 'fulfillment-status', null, Option::VALUE_OPTIONAL, '仅处理指定履约状态,逗号分隔数字(如 3,5,6);默认不限(有运单号即处理)', null ) ->addOption('repair-db', null, Option::VALUE_NONE, '仅按库内轨迹重算并修复 sign_time 列(零 HTTP,修复历史「拉取当天」污染)') ->addOption('include-gancao', null, Option::VALUE_NONE, '同时处理甘草托管单(默认跳过)') ->addOption('force', 'f', Option::VALUE_NONE, '库内已有签收时间也重新查询落库') ->addOption('sleep-ms', null, Option::VALUE_OPTIONAL, '每次 HTTP 查询后休眠毫秒,减轻快递100 压力', 200) ->addOption('detail', 'd', Option::VALUE_NONE, '打印每条明细'); } /** * @return int[]|null 解析失败(传了非法值)返回 null;未传返回默认集 */ private static function parseFulfillmentStatusOption(?string $raw): ?array { if ($raw === null || trim($raw) === '') { return self::DEFAULT_FULFILLMENT_STATUSES; } $seen = []; foreach (explode(',', $raw) as $part) { $n = (int) trim($part); if ($n >= 1 && $n <= 12) { $seen[$n] = true; } } $ids = array_keys($seen); sort($ids); return $ids !== [] ? $ids : null; } protected function execute(Input $input, Output $output): int { $dryRun = (bool) $input->getOption('dry-run'); $verbose = (bool) $input->getOption('detail'); $force = (bool) $input->getOption('force'); $includeGancao = (bool) $input->getOption('include-gancao'); $limit = max(1, (int) $input->getOption('limit')); $sleepMs = max(0, (int) $input->getOption('sleep-ms')); $days = max(0, (int) $input->getOption('days')); $onlyOrderId = $input->getOption('order-id'); $onlyOrderId = $onlyOrderId !== null && $onlyOrderId !== '' ? (int) $onlyOrderId : null; $minId = $input->getOption('min-id'); $minId = $minId !== null && $minId !== '' ? (int) $minId : null; $maxId = $input->getOption('max-id'); $maxId = $maxId !== null && $maxId !== '' ? (int) $maxId : null; $fsRaw = $input->getOption('fulfillment-status'); $fulfillmentStatuses = self::parseFulfillmentStatusOption($fsRaw !== null ? (string) $fsRaw : null); if ($fulfillmentStatuses === null) { $output->error('--fulfillment-status 格式无效,请传入 1–12 的数字,英文逗号分隔,例如:3,5,6'); return 1; } $output->writeln('========================================'); $output->writeln('批量回填业务订单签收时间(快递100 落库)'); $output->writeln('========================================'); // 纯库内修复模式:用现有轨迹重算签收时间,覆盖被写成「同步当天」的 sign_time 列(零 HTTP) if ((bool) $input->getOption('repair-db')) { $output->writeln('模式:仅修复 sign_time 列(按库内轨迹重算,不查快递100)...'); $stat = ExpressTrackingService::repairSignTimeFromDbTraces($dryRun, $dryRun ? 0 : $limit); $output->writeln(''); $output->writeln('========================================'); $output->writeln(sprintf( '修复完成:扫描已签收运单 %d,修正 sign_time %d,无需修正 %d(dry-run=%s)', $stat['scanned'], $stat['updated'], $stat['skipped'], $dryRun ? 'yes' : 'no' )); $output->writeln('========================================'); return 0; } $q = PrescriptionOrder::whereNull('delete_time') ->whereRaw("TRIM(COALESCE(`tracking_number`, '')) <> ''") ->order('id', 'desc'); if ($onlyOrderId !== null && $onlyOrderId > 0) { $q->where('id', $onlyOrderId); } else { // 空集 = 不按履约状态过滤(默认):有运单号即纳入,避免漏掉业务状态滞后的已送达单 if ($fulfillmentStatuses !== []) { $q->whereIn('fulfillment_status', $fulfillmentStatuses); } if (!$includeGancao) { $q->whereRaw("TRIM(COALESCE(`gancao_reciperl_order_no`, '')) = ''"); } if ($minId !== null && $minId > 0) { $q->where('id', '>=', $minId); } if ($maxId !== null && $maxId > 0) { $q->where('id', '<=', $maxId); } if ($days > 0) { $q->where('create_time', '>=', time() - $days * 86400); } // 多取一些候选,DB 已可解析的会被跳过,确保「需要补查」的能凑够 limit $q->limit($limit * 5); } $rows = $q->field(['id', 'order_no', 'tracking_number', 'fulfillment_status', 'create_time']) ->select() ->toArray(); if ($rows === []) { $output->warning('没有符合条件的订单。'); return 0; } // 先批量从库表解析,跳过已有签收时间的(除非 --force),避免无谓 HTTP $dbSignByPo = []; if (!$force) { $signItems = []; foreach ($rows as $r) { $signItems[] = [ 'order_id' => (int) ($r['id'] ?? 0), 'tracking_number' => trim((string) ($r['tracking_number'] ?? '')), ]; } try { $dbSignByPo = ExpressTrackingService::batchResolveSignUnixFromDbForOrders($signItems); } catch (\Throwable $e) { $output->warning('批量库表解析失败,将逐单补查:' . $e->getMessage()); $dbSignByPo = []; } } $candidates = []; $alreadyInDb = 0; foreach ($rows as $r) { $id = (int) ($r['id'] ?? 0); if ($id <= 0) { continue; } if (!$force && (int) ($dbSignByPo[$id] ?? 0) > 0) { $alreadyInDb++; continue; } $candidates[] = $r; if (count($candidates) >= $limit) { break; } } $output->writeln(sprintf( '候选 %d 条(库内已有签收 %d 条已跳过);本次最多补查 %d 条(dry-run=%s force=%s sleep-ms=%d)', count($rows), $alreadyInDb, $limit, $dryRun ? 'yes' : 'no', $force ? 'yes' : 'no', $sleepMs )); if ($candidates === []) { $output->writeln('无需补查:候选订单签收时间均已在库。'); return 0; } $resolved = 0; // 查到并落库了签收时间 $empty = 0; // 查询成功但暂无签收(在途等) $failed = 0; $totalToDo = count($candidates); // 每单一次快递100 HTTP + sleep,串行较慢;定期打印进度,避免看起来卡住。 $progressEvery = max(1, (int) min(20, max(5, (int) ceil($totalToDo / 20)))); if (!$dryRun) { $output->writeln(sprintf( '开始逐单补查(共 %d 单,每单一次快递100 查询 + %dms 间隔,请耐心等待,每 %d 单报告一次进度)...', $totalToDo, $sleepMs, $progressEvery )); } $startTs = microtime(true); $done = 0; foreach ($candidates as $r) { $id = (int) ($r['id'] ?? 0); $tn = trim((string) ($r['tracking_number'] ?? '')); if ($dryRun) { if ($verbose) { $output->writeln(sprintf('#%d %s 运单=%s (dry-run,跳过查询)', $id, (string) ($r['order_no'] ?? ''), $tn)); } continue; } try { $signTs = ExpressTrackingService::syncSignUnixFromLogisticsForPrescriptionOrder($id, true); if ($signTs > 0) { $resolved++; if ($verbose) { $output->writeln(sprintf('#%d 运单=%s → 签收 %s', $id, $tn, date('Y-m-d H:i:s', $signTs))); } } else { $empty++; if ($verbose) { $output->writeln(sprintf('#%d 运单=%s → 暂无签收(在途/未匹配)', $id, $tn)); } } } catch (\Throwable $e) { $failed++; if ($verbose) { $output->writeln("#{$id} 运单={$tn} 失败:{$e->getMessage()}"); } } $done++; // 非明细模式也给出心跳进度(明细模式已逐单打印,无需重复) if (!$verbose && ($done % $progressEvery === 0 || $done === $totalToDo)) { $elapsed = max(0.001, microtime(true) - $startTs); $rate = $done / $elapsed; $eta = $rate > 0 ? (int) round(($totalToDo - $done) / $rate) : 0; $output->writeln(sprintf( ' 进度 %d/%d(落库 %d,暂无 %d,失败 %d)已用 %ds,预计剩余 %ds', $done, $totalToDo, $resolved, $empty, $failed, (int) round($elapsed), $eta )); } if ($sleepMs > 0) { usleep($sleepMs * 1000); } } $output->writeln(''); $output->writeln('========================================'); $output->writeln(sprintf('完成:落库签收 %d,暂无签收 %d,失败 %d', $resolved, $empty, $failed)); $output->writeln('========================================'); return $failed > 0 ? 2 : 0; } }