Files
zyt/server/app/command/TcmBackfillPrescriptionOrderSignTime.php
2026-06-02 16:47:50 +08:00

304 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\command;
use app\common\model\tcm\PrescriptionOrder;
use app\common\service\ExpressTrackingService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
/**
* 批量回填处方业务订单「签收时间」到物流库(express_tracking / express_trace)。
*
* 背景:导出列表的「签收日期」来自库表(ExpressTrackingService::batchResolveSignUnixFromDbForOrders,无 HTTP)。
* 终态订单(已完成3/已签收6)被 express:auto-update 排除、或从未创建过物流追踪记录时,库内就没有签收时间,
* 导出便显示为空。本命令对这些订单逐单走一次快递100 并落库(queryAndUpdate 会写入 sign_time + 轨迹),
* 之后导出直接读库即可,既快又不空。
*
* 使用方法:
* php think tcm:backfill-sign-time --dry-run
* php think tcm:backfill-sign-time --limit=300 --sleep-ms=200
* php think tcm:backfill-sign-time --fulfillment-status=3,5,6 --days=90
* php think tcm:backfill-sign-time --order-id=474
* php think tcm:backfill-sign-time --force # 库内已有签收时间也重查
* php think tcm:backfill-sign-time --include-gancao # 同时处理甘草托管单(默认跳过,由甘草侧拉取)
*
* 履约状态代码(fulfillment_status):1待双审通过 2待发货 3已完成 4已取消 5已发货 6已签收
* 7进行中 8暂不制药 9拒收 10退款 11保留药方 12制药缓发
*
* 建议配 crontab(如每小时一次,限量推进历史欠数据):
* 0 * * * * cd /path/to/server && php think tcm:backfill-sign-time --limit=300 >> /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,无需修正 %ddry-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("<error>#{$id} 运单={$tn} 失败:{$e->getMessage()}</error>");
}
}
$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;
}
}