first commit
This commit is contained in:
@@ -0,0 +1,658 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\RevisitRateLogic;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 待分配诊单自动指派(每天定时执行)
|
||||
*
|
||||
* 使用方法:
|
||||
* php think tcm:auto-assign-pending # 正式执行
|
||||
* php think tcm:auto-assign-pending --dry-run # 只演练输出分配计划,不写库
|
||||
*
|
||||
* 推荐 cron(每天 09:00 执行一次;或经 zyt_dev_crontab 表调度):
|
||||
* 0 9 * * * cd /path/to/server && php think tcm:auto-assign-pending >> /var/log/zyt-auto-assign.log 2>&1
|
||||
*
|
||||
* 分配规则(依据上个自然月「二诊复诊接诊率」,与 RevisitRateLogic::overview 口径完全一致):
|
||||
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
|
||||
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
|
||||
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
|
||||
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
|
||||
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
|
||||
*
|
||||
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
|
||||
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
|
||||
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
|
||||
* 第一轮 3+7+5=15 条;剩余 5 条进第二轮:>70% 再各 1 条(3 条),余 2 条给 60%~70% 档前 2 人。
|
||||
*
|
||||
* 日上限跨执行累计:同一天重复执行命令时,会先从 tcm_diagnosis_auto_assign_log 扣减当日已自动分配数,不会超额。
|
||||
*
|
||||
* 日志:无论分配与否,每条待指派诊单都会写入 tcm_diagnosis_auto_assign_log,记录原因(为什么分配 / 为什么不分配)。
|
||||
* 成功分配同时写 tcm_diagnosis_assign_log(is_inherit=0,计入次月接诊率分母),与手动指派同口径。
|
||||
* 写入时 from_assistant_id 尽量带上「一中心」原医助(指派日志中最近的一中心身份,否则回退业务单创建人若属一中心),避免原医助恒为空。
|
||||
*/
|
||||
class AutoAssignPendingDiagnosis extends Command
|
||||
{
|
||||
/** 档位标识 */
|
||||
private const TIER_GT70 = 'gt70';
|
||||
private const TIER_60_70 = '60_70';
|
||||
private const TIER_50_60 = '50_60';
|
||||
|
||||
/** 各档位当日每人分配上限(按优先级排列,先高档后低档) */
|
||||
private const TIER_DAILY_CAPS = [
|
||||
self::TIER_GT70 => 3,
|
||||
self::TIER_60_70 => 2,
|
||||
self::TIER_50_60 => 1,
|
||||
];
|
||||
|
||||
private const TIER_LABELS = [
|
||||
self::TIER_GT70 => '>70%',
|
||||
self::TIER_60_70 => '60%~70%',
|
||||
self::TIER_50_60 => '50%~60%',
|
||||
];
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('tcm:auto-assign-pending')
|
||||
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$now = time();
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$runDate = date('Y-m-d', $now);
|
||||
$statMonth = date('Y-m', strtotime(date('Y-m-01', $now) . ' -1 month'));
|
||||
$batchNo = date('YmdHis', $now) . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
|
||||
date('Y-m-d H:i:s', $now),
|
||||
$batchNo,
|
||||
$statMonth,
|
||||
$dryRun ? '(演练模式,不写库)'
|
||||
: ''
|
||||
));
|
||||
|
||||
try {
|
||||
// 1. 上月二诊复诊接诊率 → 医助分档
|
||||
$tiers = $this->buildAssistantTiers($statMonth);
|
||||
$tierTotal = array_sum(array_map('count', $tiers));
|
||||
$output->writeln(sprintf(
|
||||
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
|
||||
\count($tiers[self::TIER_GT70]),
|
||||
\count($tiers[self::TIER_60_70]),
|
||||
\count($tiers[self::TIER_50_60])
|
||||
));
|
||||
|
||||
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
|
||||
$remaining = $this->buildRemainingQuota($tiers, $runDate);
|
||||
|
||||
// 3. 待指派池:与「待分配医助」Tab 同口径(assistant_id 空/0 + 当月有业务订单),先到先分
|
||||
[$pool, $ineligible] = $this->fetchPendingPool($now);
|
||||
$output->writeln(sprintf('待指派池:符合条件 %d 条,不符合条件 %d 条', \count($pool), \count($ineligible)));
|
||||
|
||||
$logRows = [];
|
||||
|
||||
// 不符合条件的待指派诊单:不分配,逐条记录原因
|
||||
foreach ($ineligible as $item) {
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diag'], [
|
||||
'action' => 0,
|
||||
'reason' => $item['reason'],
|
||||
], $now);
|
||||
}
|
||||
|
||||
if ($pool === []) {
|
||||
$this->flushLogs($logRows, $dryRun, $output);
|
||||
$output->writeln('待指派池为空,本次无需分配。');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($tierTotal === 0) {
|
||||
$reason = sprintf('未分配:上月(%s)无二诊复诊接诊率≥50%%的医助,本批次不执行分配', $statMonth);
|
||||
foreach ($pool as $diag) {
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
|
||||
'action' => 0,
|
||||
'reason' => $reason,
|
||||
], $now);
|
||||
}
|
||||
$this->flushLogs($logRows, $dryRun, $output);
|
||||
$output->writeln($reason);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 4. 轮询排分配计划
|
||||
[$plan, $leftover] = $this->buildAssignPlan($pool, $tiers, $remaining);
|
||||
|
||||
// 5. 执行计划(逐条事务 + 行锁复核,落库诊单/指派日志/自动指派日志)
|
||||
$assigned = 0;
|
||||
$skipped = 0;
|
||||
foreach ($plan as $item) {
|
||||
if ($dryRun) {
|
||||
$assigned++;
|
||||
$output->writeln(sprintf(
|
||||
'[演练] 诊单#%d(%s) → %s(%s,第%d轮,当日第%d/%d条)',
|
||||
$item['diagnosis']['id'],
|
||||
(string) $item['diagnosis']['patient_name'],
|
||||
$item['assistant']['name'],
|
||||
self::TIER_LABELS[$item['tier']],
|
||||
$item['round'],
|
||||
$item['day_seq'],
|
||||
self::TIER_DAILY_CAPS[$item['tier']]
|
||||
));
|
||||
continue;
|
||||
}
|
||||
$ok = $this->applyAssignment($item, $now);
|
||||
if ($ok) {
|
||||
$assigned++;
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
|
||||
'action' => 1,
|
||||
'assistant_id' => $item['assistant']['id'],
|
||||
'assistant_name' => $item['assistant']['name'],
|
||||
'tier' => $item['tier'],
|
||||
'visit2_rate' => $item['assistant']['rate'],
|
||||
'round_no' => $item['round'],
|
||||
'reason' => sprintf(
|
||||
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
|
||||
$item['assistant']['name'],
|
||||
$item['assistant']['id'],
|
||||
$statMonth,
|
||||
$item['assistant']['rate'],
|
||||
self::TIER_LABELS[$item['tier']],
|
||||
self::TIER_DAILY_CAPS[$item['tier']],
|
||||
$item['round'],
|
||||
$item['day_seq']
|
||||
),
|
||||
], $now);
|
||||
} else {
|
||||
$skipped++;
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
|
||||
'action' => 0,
|
||||
'reason' => '未分配:执行时诊单已被指派给其他医助(并发/人工抢先),本次跳过',
|
||||
], $now);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 额度用尽后剩余的待指派诊单:不分配,记录原因
|
||||
foreach ($leftover as $diag) {
|
||||
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
|
||||
'action' => 0,
|
||||
'reason' => '未分配:各档位医助当日剩余额度已用尽(>70%每人3条、60%~70%每人2条、50%~60%每人1条),顺延至下次执行',
|
||||
], $now);
|
||||
}
|
||||
|
||||
$this->flushLogs($logRows, $dryRun, $output);
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'执行完成。计划分配: %d, 实际分配: %d, 并发跳过: %d, 额度不足未分: %d, 不符合条件: %d',
|
||||
\count($plan),
|
||||
$assigned,
|
||||
$skipped,
|
||||
\count($leftover),
|
||||
\count($ineligible)
|
||||
));
|
||||
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('待分配诊单自动指派异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
$output->error('执行异常: ' . $e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按上月二诊复诊接诊率把医助分档(复用 RevisitRateLogic 口径),并剔除已禁用/已删除账号。
|
||||
*
|
||||
* @return array{gt70: list<array{id:int,name:string,rate:float}>, 60_70: list<array{id:int,name:string,rate:float}>, 50_60: list<array{id:int,name:string,rate:float}>}
|
||||
*/
|
||||
private function buildAssistantTiers(string $statMonth): array
|
||||
{
|
||||
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
|
||||
|
||||
/** @var list<array{id:int,name:string,rate:float}> $candidates */
|
||||
$candidates = [];
|
||||
foreach ($overview['rows'] ?? [] as $deptRow) {
|
||||
foreach ($deptRow['children'] ?? [] as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$rate = $row['visit2_rate'] ?? null;
|
||||
// 上月无被指派数据(rate=null)或接诊率低于 50% 的医助不参与分配
|
||||
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
|
||||
continue;
|
||||
}
|
||||
$candidates[] = [
|
||||
'id' => $aid,
|
||||
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
|
||||
'rate' => (float) $rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($candidates === []) {
|
||||
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||
}
|
||||
|
||||
// 只分配给当前在职可用的医助账号(role_id=2 且未禁用未删除),
|
||||
// 且当前部门须在「二中心」子树内(统计月在二中心、后来调离的不再参与)
|
||||
$erDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||
if ($erDeptSet === []) {
|
||||
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||
}
|
||||
$activeIds = Db::name('admin')
|
||||
->alias('a')
|
||||
->join('admin_role ar', 'a.id = ar.admin_id')
|
||||
->join('admin_dept ad', 'a.id = ad.admin_id')
|
||||
->where('ar.role_id', 2)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->whereIn('ad.dept_id', array_keys($erDeptSet))
|
||||
->whereIn('a.id', array_column($candidates, 'id'))
|
||||
->group('a.id')
|
||||
->column('a.id');
|
||||
$activeSet = array_fill_keys(array_map('intval', $activeIds), true);
|
||||
|
||||
$tiers = [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||
foreach ($candidates as $c) {
|
||||
if (!isset($activeSet[$c['id']])) {
|
||||
continue;
|
||||
}
|
||||
if ($c['rate'] > 70.0) {
|
||||
$tiers[self::TIER_GT70][] = $c;
|
||||
} elseif ($c['rate'] > 60.0) {
|
||||
$tiers[self::TIER_60_70][] = $c;
|
||||
} else { // 50 <= rate <= 60
|
||||
$tiers[self::TIER_50_60][] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
// 档内按接诊率降序、id 升序,保证分配顺序确定可复现
|
||||
foreach ($tiers as &$list) {
|
||||
usort($list, static function (array $a, array $b): int {
|
||||
if ($a['rate'] !== $b['rate']) {
|
||||
return $b['rate'] <=> $a['rate'];
|
||||
}
|
||||
|
||||
return $a['id'] <=> $b['id'];
|
||||
});
|
||||
}
|
||||
unset($list);
|
||||
|
||||
return $tiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 各医助当日剩余额度 = 档位日上限 - 当日已自动分配条数(同日重复执行不超额)。
|
||||
*
|
||||
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
|
||||
*
|
||||
* @return array<int, int> assistant_id => 剩余额度
|
||||
*/
|
||||
private function buildRemainingQuota(array $tiers, string $runDate): array
|
||||
{
|
||||
$usedToday = Db::name('tcm_diagnosis_auto_assign_log')
|
||||
->where('run_date', $runDate)
|
||||
->where('action', 1)
|
||||
->where('assistant_id', '>', 0)
|
||||
->where('rollback_time', 0)
|
||||
->group('assistant_id')
|
||||
->column('COUNT(*)', 'assistant_id');
|
||||
|
||||
$remaining = [];
|
||||
foreach (self::TIER_DAILY_CAPS as $tier => $cap) {
|
||||
foreach ($tiers[$tier] as $assistant) {
|
||||
$used = (int) ($usedToday[$assistant['id']] ?? 0);
|
||||
$remaining[$assistant['id']] = max(0, $cap - $used);
|
||||
}
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* 待指派池:与后台「待分配医助」Tab 同口径 —— assistant_id 为空/0、未删除,
|
||||
* 且当月内存在业务订单(order.patient_id = 诊单 id,Tab 默认按当月过滤)。先到先分。
|
||||
* 返回 [符合条件, 不符合条件(附原因)] 两组;不符合条件的诊单不分配,只记日志。
|
||||
*
|
||||
* @return array{0: list<array<string,mixed>>, 1: list<array{diag:array<string,mixed>,reason:string}>}
|
||||
*/
|
||||
private function fetchPendingPool(int $now): array
|
||||
{
|
||||
$rows = Db::name('tcm_diagnosis')
|
||||
->whereRaw('(assistant_id IS NULL OR assistant_id = 0)')
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'patient_name', 'phone', 'status', 'create_time'])
|
||||
->order(['create_time' => 'asc', 'id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
if ($rows === []) {
|
||||
return [[], []];
|
||||
}
|
||||
|
||||
// 当月存在业务订单的诊单集合(order.create_time 兼容整型时间戳与 datetime 字符串,与 DiagnosisLists 一致)
|
||||
$tStart = (int) strtotime(date('Y-m-01 00:00:00', $now));
|
||||
$tEnd = (int) strtotime(date('Y-m-t 23:59:59', $now));
|
||||
$dsStart = date('Y-m-d H:i:s', $tStart);
|
||||
$dsEnd = date('Y-m-d H:i:s', $tEnd);
|
||||
$hasOrderSet = [];
|
||||
foreach (array_chunk(array_column($rows, 'id'), 2000) as $chunk) {
|
||||
$ids = Db::name('order')
|
||||
->whereIn('patient_id', $chunk)
|
||||
->whereNull('delete_time')
|
||||
->where(static function ($q) use ($tStart, $tEnd, $dsStart, $dsEnd) {
|
||||
$q->whereBetween('create_time', [$tStart, $tEnd])
|
||||
->whereOr(static function ($q2) use ($dsStart, $dsEnd) {
|
||||
$q2->where('create_time', '>=', $dsStart)->where('create_time', '<=', $dsEnd);
|
||||
});
|
||||
})
|
||||
->group('patient_id')
|
||||
->column('patient_id');
|
||||
foreach ($ids as $id) {
|
||||
$hasOrderSet[(int) $id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$curMonth = date('Y-m', $now);
|
||||
$eligible = [];
|
||||
$ineligible = [];
|
||||
foreach ($rows as $r) {
|
||||
$did = (int) ($r['id'] ?? 0);
|
||||
if (!isset($hasOrderSet[$did])) {
|
||||
$ineligible[] = [
|
||||
'diag' => $r,
|
||||
'reason' => sprintf('未分配:诊单当月(%s)无业务订单,不在「待分配医助」列表范围内,不满足自动分配条件', $curMonth),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if ((int) ($r['status'] ?? 0) !== 1) {
|
||||
$ineligible[] = [
|
||||
'diag' => $r,
|
||||
'reason' => '未分配:诊单未启用(status≠1),不满足自动分配条件',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$eligible[] = $r;
|
||||
}
|
||||
|
||||
return [$eligible, $ineligible];
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询排分配计划:每轮内先 >70% 档每人 1 条,再 60%~70%,再 50%~60%;受各自当日剩余额度约束。
|
||||
*
|
||||
* @param list<array<string,mixed>> $pool 待指派诊单(先到先分)
|
||||
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
|
||||
* @param array<int, int> $remaining assistant_id => 剩余额度(会被消耗)
|
||||
*
|
||||
* @return array{
|
||||
* 0: list<array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float},tier:string,round:int,day_seq:int}>,
|
||||
* 1: list<array<string,mixed>>
|
||||
* } [分配计划, 额度用尽后剩余诊单]
|
||||
*/
|
||||
private function buildAssignPlan(array $pool, array $tiers, array $remaining): array
|
||||
{
|
||||
$plan = [];
|
||||
$poolIdx = 0;
|
||||
$poolCount = \count($pool);
|
||||
/** @var array<int, int> $daySeq 医助当日已排序号(含历史已用额度) */
|
||||
$daySeq = [];
|
||||
foreach ($remaining as $aid => $left) {
|
||||
// 起始序号 = 日上限 - 剩余额度(同日多次执行时序号衔接)
|
||||
$cap = 0;
|
||||
foreach (self::TIER_DAILY_CAPS as $tier => $tierCap) {
|
||||
foreach ($tiers[$tier] as $assistant) {
|
||||
if ($assistant['id'] === $aid) {
|
||||
$cap = $tierCap;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$daySeq[$aid] = $cap - $left;
|
||||
}
|
||||
|
||||
$round = 0;
|
||||
while ($poolIdx < $poolCount) {
|
||||
$round++;
|
||||
$assignedThisRound = 0;
|
||||
foreach (self::TIER_DAILY_CAPS as $tier => $_cap) {
|
||||
foreach ($tiers[$tier] as $assistant) {
|
||||
if ($poolIdx >= $poolCount) {
|
||||
break 2;
|
||||
}
|
||||
$aid = $assistant['id'];
|
||||
if (($remaining[$aid] ?? 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$remaining[$aid]--;
|
||||
$daySeq[$aid]++;
|
||||
$plan[] = [
|
||||
'diagnosis' => $pool[$poolIdx],
|
||||
'assistant' => $assistant,
|
||||
'tier' => $tier,
|
||||
'round' => $round,
|
||||
'day_seq' => $daySeq[$aid],
|
||||
];
|
||||
$poolIdx++;
|
||||
$assignedThisRound++;
|
||||
}
|
||||
}
|
||||
if ($assignedThisRound === 0) {
|
||||
// 所有医助额度用尽,剩余诊单不再分配
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [$plan, \array_slice($pool, $poolIdx)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 落库单条分配:事务 + 行锁复核诊单仍未指派,更新诊单并写指派日志(与手动指派 DiagnosisLogic::assign 同口径)。
|
||||
*
|
||||
* @param array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float}} $item
|
||||
*/
|
||||
private function applyAssignment(array $item, int $now): bool
|
||||
{
|
||||
$diagnosisId = (int) $item['diagnosis']['id'];
|
||||
$toAssistantId = (int) $item['assistant']['id'];
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$diagLock = Db::name('tcm_diagnosis')
|
||||
->where('id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->field(['id', 'assistant_id'])
|
||||
->find();
|
||||
if ($diagLock === null || $diagLock === [] || (int) ($diagLock['assistant_id'] ?? 0) > 0) {
|
||||
Db::rollback();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Db::name('tcm_diagnosis')
|
||||
->where('id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->update([
|
||||
'assistant_id' => $toAssistantId,
|
||||
'assign_read_at' => null,
|
||||
]);
|
||||
|
||||
$poSnap = Db::name('tcm_prescription_order')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->whereNull('delete_time')
|
||||
->order(['create_time' => 'desc', 'id' => 'desc'])
|
||||
->field(['creator_id', 'create_time'])
|
||||
->find();
|
||||
$relatedPoCreatorId = (int) ($poSnap['creator_id'] ?? 0);
|
||||
$relatedPoCreateTime = (int) ($poSnap['create_time'] ?? 0);
|
||||
if ($relatedPoCreateTime <= 0) {
|
||||
$relatedPoCreateTime = $now;
|
||||
$relatedPoCreatorId = 0;
|
||||
}
|
||||
|
||||
// 自动分给二中心时,原医助带上一中心身份(列表「原医助」不再恒为空)
|
||||
$fromAssistantId = $this->resolveYiCenterPreviousAssistantId($diagnosisId, $relatedPoCreatorId);
|
||||
|
||||
Db::name('tcm_diagnosis_assign_log')->insert([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'from_assistant_id' => $fromAssistantId,
|
||||
'to_assistant_id' => $toAssistantId,
|
||||
'operator_admin_id' => 0,
|
||||
'operator_name' => '系统自动分配',
|
||||
'operator_account' => 'system',
|
||||
'ip' => '',
|
||||
'related_po_creator_id' => $relatedPoCreatorId,
|
||||
'related_po_create_time' => $relatedPoCreateTime,
|
||||
'is_inherit' => 0,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
Log::error(sprintf('自动指派落库失败 diagnosis_id=%d assistant_id=%d msg=%s', $diagnosisId, $toAssistantId, $e->getMessage()));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析诊单对应的「一中心」原医助 id,供自动指派写入 from_assistant_id。
|
||||
* 优先:指派日志由新到旧,取最近出现的一中心身份(先 to 后 from);
|
||||
* 回退:最新业务单创建人若属一中心则用之。跳过二中心身份,避免原医助写成上一任二中心。
|
||||
*/
|
||||
private function resolveYiCenterPreviousAssistantId(int $diagnosisId, int $relatedPoCreatorId): int
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$yiDeptSet = DeptLogic::getYiCenterSubtreeDeptIdSet();
|
||||
if ($yiDeptSet === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$logs = Db::name('tcm_diagnosis_assign_log')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->order('id', 'desc')
|
||||
->field(['from_assistant_id', 'to_assistant_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$candidateIds = [];
|
||||
foreach ($logs as $log) {
|
||||
$to = (int) ($log['to_assistant_id'] ?? 0);
|
||||
$from = (int) ($log['from_assistant_id'] ?? 0);
|
||||
if ($to > 0) {
|
||||
$candidateIds[] = $to;
|
||||
}
|
||||
if ($from > 0) {
|
||||
$candidateIds[] = $from;
|
||||
}
|
||||
}
|
||||
if ($relatedPoCreatorId > 0) {
|
||||
$candidateIds[] = $relatedPoCreatorId;
|
||||
}
|
||||
$candidateIds = array_values(array_unique(array_filter($candidateIds)));
|
||||
if ($candidateIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$yiAdminSet = $this->filterAdminIdsInDeptSet($candidateIds, $yiDeptSet);
|
||||
if ($yiAdminSet === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$to = (int) ($log['to_assistant_id'] ?? 0);
|
||||
if ($to > 0 && isset($yiAdminSet[$to])) {
|
||||
return $to;
|
||||
}
|
||||
$from = (int) ($log['from_assistant_id'] ?? 0);
|
||||
if ($from > 0 && isset($yiAdminSet[$from])) {
|
||||
return $from;
|
||||
}
|
||||
}
|
||||
if ($relatedPoCreatorId > 0 && isset($yiAdminSet[$relatedPoCreatorId])) {
|
||||
return $relatedPoCreatorId;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $adminIds
|
||||
* @param array<int, true> $deptIdSet
|
||||
*
|
||||
* @return array<int, true> admin_id => true
|
||||
*/
|
||||
private function filterAdminIdsInDeptSet(array $adminIds, array $deptIdSet): array
|
||||
{
|
||||
if ($adminIds === [] || $deptIdSet === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('admin_dept')
|
||||
->whereIn('admin_id', $adminIds)
|
||||
->whereIn('dept_id', array_keys($deptIdSet))
|
||||
->column('admin_id');
|
||||
|
||||
return array_fill_keys(array_map('intval', $rows), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $diag 诊单行(含 id/patient_name/phone)
|
||||
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/round_no/reason
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildLogRow(string $batchNo, string $runDate, string $statMonth, array $diag, array $extra, int $now): array
|
||||
{
|
||||
return [
|
||||
'batch_no' => $batchNo,
|
||||
'run_date' => $runDate,
|
||||
'stat_month' => $statMonth,
|
||||
'diagnosis_id' => (int) ($diag['id'] ?? 0),
|
||||
'patient_name' => mb_substr(trim((string) ($diag['patient_name'] ?? '')), 0, 64),
|
||||
'patient_phone' => mb_substr(trim((string) ($diag['phone'] ?? '')), 0, 32),
|
||||
'action' => (int) ($extra['action'] ?? 0),
|
||||
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
|
||||
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
|
||||
'tier' => (string) ($extra['tier'] ?? ''),
|
||||
'visit2_rate' => $extra['visit2_rate'] ?? null,
|
||||
'round_no' => (int) ($extra['round_no'] ?? 0),
|
||||
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
|
||||
'create_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $logRows
|
||||
*/
|
||||
private function flushLogs(array $logRows, bool $dryRun, Output $output): void
|
||||
{
|
||||
if ($logRows === []) {
|
||||
return;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf('[演练] 应写入自动指派日志 %d 条(未落库)', \count($logRows)));
|
||||
|
||||
return;
|
||||
}
|
||||
foreach (array_chunk($logRows, 500) as $chunk) {
|
||||
Db::name('tcm_diagnosis_auto_assign_log')->insertAll($chunk);
|
||||
}
|
||||
$output->writeln(sprintf('已写入自动指派日志 %d 条', \count($logRows)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\DiagnosisTodo;
|
||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 诊单待办事项 - 企业微信定时推送
|
||||
*
|
||||
* 使用方法:
|
||||
* php think tcm:diagnosis-todo-notify
|
||||
*
|
||||
* 推荐 cron(每分钟执行):
|
||||
* * * * * * cd /path/to/server && php think tcm:diagnosis-todo-notify >> /var/log/zyt-todo.log 2>&1
|
||||
*
|
||||
* 行为:
|
||||
* 1. 取出 status=0 且 remind_time<=now 的待办,按 remind_time asc,限 200 条
|
||||
* 2. 每条用乐观锁 (update set status=1 where id=? and status=0) 抢占,避免并发重复推送
|
||||
* 3. 找创建人 admin.work_wechat_userid,未绑定 → status=3 + error
|
||||
* 4. 调 WechatWorkAppMessageService::sendTextToUser,失败 → status=3 + error(终态,不重试)
|
||||
* 5. 整批不中断;输出 处理 N / 成功 X / 失败 Y / 跳过未绑定 Z
|
||||
*/
|
||||
class DiagnosisTodoNotify extends Command
|
||||
{
|
||||
/** 单次扫描最大条数 */
|
||||
private const BATCH_LIMIT = 200;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('tcm:diagnosis-todo-notify')
|
||||
->setDescription('诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$startTs = microtime(true);
|
||||
$now = time();
|
||||
|
||||
$output->writeln('[' . date('Y-m-d H:i:s', $now) . '] 开始扫描诊单待办事项...');
|
||||
|
||||
$totalProcessed = 0;
|
||||
$totalSent = 0;
|
||||
$totalFailed = 0;
|
||||
$totalSkipped = 0; // 被并发抢占
|
||||
$totalUnbound = 0; // 创建人未绑定企微(计入 failed)
|
||||
|
||||
try {
|
||||
$todoTable = (new DiagnosisTodo())->getTable();
|
||||
|
||||
$rows = Db::name(self::stripTablePrefix($todoTable))
|
||||
->where('status', DiagnosisTodo::STATUS_PENDING)
|
||||
->where('remind_time', '<=', $now)
|
||||
->whereNull('delete_time')
|
||||
->order('remind_time', 'asc')
|
||||
->limit(self::BATCH_LIMIT)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$output->writeln('待处理数量:' . count($rows));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$totalProcessed++;
|
||||
$todoId = (int) ($row['id'] ?? 0);
|
||||
if ($todoId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 乐观锁:抢占成功时影响 1 行;并发场景下另一个进程已抢走则影响 0 行 → 跳过
|
||||
$affected = Db::name(self::stripTablePrefix($todoTable))
|
||||
->where('id', $todoId)
|
||||
->where('status', DiagnosisTodo::STATUS_PENDING)
|
||||
->update([
|
||||
'status' => DiagnosisTodo::STATUS_SENT,
|
||||
'notified_at' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
if ($affected <= 0) {
|
||||
$totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 取创建人 work_wechat_userid
|
||||
$creatorId = (int) ($row['creator_id'] ?? 0);
|
||||
$wxId = '';
|
||||
if ($creatorId > 0) {
|
||||
$wxId = (string) Admin::where('id', $creatorId)->value('work_wechat_userid');
|
||||
}
|
||||
|
||||
if ($wxId === '') {
|
||||
$this->markFailed($todoTable, $todoId, '创建人未绑定企业微信 userid');
|
||||
$totalUnbound++;
|
||||
$totalFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 拼推送文本
|
||||
$text = $this->buildText($row);
|
||||
|
||||
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
|
||||
if (!($res['ok'] ?? false)) {
|
||||
$errMsg = (string) ($res['message'] ?? '企业微信推送失败');
|
||||
$this->markFailed($todoTable, $todoId, $errMsg);
|
||||
$totalFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalSent++;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('诊单待办推送异常 todo_id=' . $todoId . ' msg=' . $e->getMessage());
|
||||
try {
|
||||
$this->markFailed($todoTable, $todoId, '系统异常: ' . $e->getMessage());
|
||||
} catch (\Throwable $ee) {
|
||||
Log::error('回写失败状态出错 todo_id=' . $todoId . ' msg=' . $ee->getMessage());
|
||||
}
|
||||
$totalFailed++;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('诊单待办批处理异常: ' . $e->getMessage());
|
||||
$output->error('批处理异常: ' . $e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTs, 3);
|
||||
$output->writeln(sprintf(
|
||||
'处理完成。总数: %d, 成功: %d, 失败: %d (其中未绑定企微: %d), 并发跳过: %d, 耗时: %ss',
|
||||
$totalProcessed,
|
||||
$totalSent,
|
||||
$totalFailed,
|
||||
$totalUnbound,
|
||||
$totalSkipped,
|
||||
$duration
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接推送文本
|
||||
*
|
||||
* @param array<string,mixed> $row 待办记录原始行
|
||||
*/
|
||||
private function buildText(array $row): string
|
||||
{
|
||||
$patientName = '';
|
||||
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
|
||||
if ($diagnosisId > 0) {
|
||||
$patientName = (string) Diagnosis::where('id', $diagnosisId)->value('patient_name');
|
||||
}
|
||||
|
||||
$remindAt = (int) ($row['remind_time'] ?? 0);
|
||||
$content = trim((string) ($row['content'] ?? ''));
|
||||
|
||||
$lines = [
|
||||
'【患者跟踪提醒】',
|
||||
'患者:' . ($patientName !== '' ? $patientName : '-'),
|
||||
'时间:' . ($remindAt > 0 ? date('Y-m-d H:i', $remindAt) : '-'),
|
||||
'内容:' . ($content !== '' ? $content : '-'),
|
||||
'— 二中心跟踪系统',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记一条待办为「发送失败」,写入 error 字段
|
||||
*/
|
||||
private function markFailed(string $todoTable, int $todoId, string $errMsg): void
|
||||
{
|
||||
$errMsg = mb_substr($errMsg, 0, 500);
|
||||
Db::name(self::stripTablePrefix($todoTable))
|
||||
->where('id', $todoId)
|
||||
->update([
|
||||
'status' => DiagnosisTodo::STATUS_FAILED,
|
||||
'error' => $errMsg,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Db::name() 接收的是不带前缀的表名,BloodRecord 等 Model::getTable() 返回的是带前缀的全名。
|
||||
* 这里去掉首段 `<prefix>_`。注意:项目实际前缀是 `zyt_`,但 think-orm 的 Db::name()
|
||||
* 内部会用 config('database.prefix') 自动拼回,所以这里只需剥离一次即可。
|
||||
*/
|
||||
private static function stripTablePrefix(string $tableWithPrefix): string
|
||||
{
|
||||
$prefix = (string) config('database.connections.mysql.prefix', '');
|
||||
if ($prefix !== '' && str_starts_with($tableWithPrefix, $prefix)) {
|
||||
return substr($tableWithPrefix, strlen($prefix));
|
||||
}
|
||||
|
||||
return $tableWithPrefix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineBootstrapService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class EjPharmacyBootstrapMedicines extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('ej-pharmacy:bootstrap-medicines')
|
||||
->setDescription('一次性导入并原子替换恩济药房药材目录投影')
|
||||
->addOption('replace', null, Option::VALUE_NONE, '确认替换本地 EJ 药材投影')
|
||||
->addOption('confirm', null, Option::VALUE_OPTIONAL, '破坏性操作确认令牌:RESET_TEST_CATALOG', '')
|
||||
->addOption('batch-size', null, Option::VALUE_OPTIONAL, '远端导入批次大小(1-500)', 100);
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
try {
|
||||
$batchSize = (int) $input->getOption('batch-size');
|
||||
EjMedicineBootstrapService::assertCommandGate(
|
||||
(bool) $input->getOption('replace'),
|
||||
(string) $input->getOption('confirm'),
|
||||
$batchSize
|
||||
);
|
||||
$result = EjMedicineBootstrapService::execute($batchSize);
|
||||
$output->writeln(sprintf(
|
||||
'bootstrap 完成 source=%d batches=%d catalog=%d active_mappings=%d unmapped=%d',
|
||||
$result['source_count'],
|
||||
$result['batch_count'],
|
||||
$result['catalog'],
|
||||
$result['active_mappings'],
|
||||
$result['unmapped']
|
||||
));
|
||||
return 0;
|
||||
} catch (\Throwable $exception) {
|
||||
$output->error($exception->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
class EjPharmacySyncCatalog extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('ej-pharmacy:sync-catalog')
|
||||
->setDescription('增量同步洛阳药房 ERP 药材目录')
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '每页数量', 200);
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
try {
|
||||
$stats = EjMedicineCatalogSyncService::sync(max((int) $input->getOption('limit'), 1));
|
||||
$output->writeln(sprintf(
|
||||
'同步完成 pages=%d pulled=%d created=%d updated=%d deactivated=%d cursor=%d',
|
||||
$stats['pages'],
|
||||
$stats['pulled'],
|
||||
$stats['created'],
|
||||
$stats['updated'],
|
||||
$stats['deactivated'],
|
||||
$stats['cursor']
|
||||
));
|
||||
return 0;
|
||||
} catch (\Throwable $exception) {
|
||||
$output->error($exception->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 物流自动更新定时任务
|
||||
*
|
||||
* 使用方法:
|
||||
* php think express:auto-update
|
||||
*
|
||||
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货」核对释放诊单医助(跳过已完成/已签收业务单,与是否甘草单无关)
|
||||
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
|
||||
*
|
||||
* 已对「待释放医助非二中心」执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||
*/
|
||||
class ExpressAutoUpdate extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('express:auto-update')
|
||||
->setDescription('自动更新物流追踪信息');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$output->writeln('开始自动更新物流信息...');
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$result = ExpressTrackingService::autoUpdateBatch(1000);
|
||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(1000);
|
||||
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
|
||||
$output->writeln("更新完成!");
|
||||
$output->writeln("总数: {$result['total']}");
|
||||
$output->writeln("成功: {$result['success']}");
|
||||
$output->writeln("失败: {$result['failed']}");
|
||||
$output->writeln("医助已移除(物流任务+指派日志): " . (int) ($result['assistant_cleared'] ?? 0));
|
||||
$output->writeln("医助已移除(履约已发货核对): " . (int) ($recon['cleared'] ?? 0) . " (扫描 " . (int) ($recon['scanned'] ?? 0) . " 单)");
|
||||
$lines = array_merge($result['assistant_lines'] ?? [], $recon['lines'] ?? []);
|
||||
if ($lines === []) {
|
||||
$output->writeln('医助明细: (无)');
|
||||
} else {
|
||||
$output->writeln('医助明细:');
|
||||
foreach ($lines as $line) {
|
||||
$output->writeln(' ' . $line);
|
||||
}
|
||||
}
|
||||
$output->writeln("耗时: {$duration}秒");
|
||||
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
$output->error("更新失败: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use app\common\service\gancao\GancaoLogisticsRouteService;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 同步甘草订单的物流路由信息到本地物流追踪表
|
||||
*
|
||||
* 数据流:
|
||||
* zyt_tcm_prescription_order (甘草已上传)
|
||||
* ↓ 调用 igc_scm.logistics.client_opt.pull / GET_TASK_ROUTE_LIST
|
||||
* ↓ 甘草报快递任务不存在等(如 10101)且业务单已有运单号 → 降级快递100
|
||||
* zyt_express_tracking + zyt_express_trace + zyt_express_state_log + zyt_express_query_log
|
||||
*
|
||||
* 使用方法:
|
||||
* php think gancao:sync-logistics 默认拉 2000 单(跳过已完成/已签收)
|
||||
* php think gancao:sync-logistics --limit=500 自定义拉取上限
|
||||
* php think gancao:sync-logistics --order-id=1424 只跑指定订单(数字 id)
|
||||
* php think gancao:sync-logistics --order-id=PO20260530165158645023 或 PO 业务单号
|
||||
* php think gancao:sync-logistics -t SF1234567890 按快递单号走快递100 查询并落库
|
||||
* php think gancao:sync-logistics --detail 打印每单详细
|
||||
*
|
||||
* 建议 crontab(每 30 分钟执行一次):
|
||||
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics >> runtime/log/gancao_sync.log 2>&1
|
||||
*
|
||||
* 跳过履约状态为已完成(3)、已签收(6) 的业务订单,不再拉取甘草路由。
|
||||
* 已对「待释放医助非二中心」(发货/签收自动释放规则见 ExpressTrackingService)的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||
*/
|
||||
class GancaoSyncLogisticsRoute extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('gancao:sync-logistics')
|
||||
->setDescription('同步甘草订单的物流路由(GET_TASK_ROUTE_LIST)到本地物流追踪表')
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单(跳过已完成/已签收)', 2000)
|
||||
->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定订单:prescription_order.id 或 PO 业务单号 order_no', null)
|
||||
->addOption('tracking-number', 't', Option::VALUE_REQUIRED, '按快递单号走快递100 查询并落库')
|
||||
->addOption('detail', 'd', Option::VALUE_NONE, '打印每单详细结果');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$onlyOrderId = null;
|
||||
$onlyOrderNo = '';
|
||||
$orderIdArg = $input->getOption('order-id');
|
||||
if ($orderIdArg !== null && trim((string) $orderIdArg) !== '') {
|
||||
$resolved = self::resolvePrescriptionOrderId((string) $orderIdArg);
|
||||
if ($resolved === null) {
|
||||
$output->error('未找到订单:' . trim((string) $orderIdArg) . '(--order-id 支持数字 id 或 PO 业务单号)');
|
||||
|
||||
return 1;
|
||||
}
|
||||
$onlyOrderId = $resolved['id'];
|
||||
$onlyOrderNo = $resolved['order_no'];
|
||||
}
|
||||
$trackingNumber = trim((string) $input->getOption('tracking-number'));
|
||||
$verbose = (bool) $input->getOption('detail');
|
||||
|
||||
if ($trackingNumber !== '' && $onlyOrderId !== null) {
|
||||
$output->error('请勿同时使用 --tracking-number 与 --order-id');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('========================================');
|
||||
$output->writeln($trackingNumber !== '' ? '快递100 物流查询' : '甘草物流路由同步');
|
||||
$output->writeln('========================================');
|
||||
|
||||
if ($trackingNumber === '' && !GancaoScmRecipelService::isConfigured()) {
|
||||
$output->error('甘草 SCM 未配置:' . GancaoScmRecipelService::whyNotConfigured());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
if ($trackingNumber !== '') {
|
||||
$output->writeln('开始查询...(快递100,tracking_number=' . $trackingNumber . ')');
|
||||
} else {
|
||||
if ($onlyOrderId !== null) {
|
||||
$output->writeln('开始同步...(指定单 order_id=' . $onlyOrderId . ($onlyOrderNo !== '' ? ', order_no=' . $onlyOrderNo : '') . ')');
|
||||
} else {
|
||||
$output->writeln('开始同步...(limit=' . $limit . ')');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ($trackingNumber !== '') {
|
||||
$stats = ExpressTrackingService::queryKuaidiByTrackingNumber($trackingNumber);
|
||||
$stats['reconcile_cleared'] = 0;
|
||||
$stats['reconcile_scanned'] = 0;
|
||||
$stats['reconcile_lines'] = [];
|
||||
} else {
|
||||
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
|
||||
if ($onlyOrderId !== null) {
|
||||
$stats['reconcile_cleared'] = 0;
|
||||
$stats['reconcile_scanned'] = 0;
|
||||
$stats['reconcile_lines'] = [];
|
||||
} else {
|
||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(2000);
|
||||
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
|
||||
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
|
||||
$stats['reconcile_lines'] = $recon['lines'] ?? [];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->error('同步异常:' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $start, 2);
|
||||
|
||||
if ($verbose && !empty($stats['details'])) {
|
||||
$output->writeln('');
|
||||
$output->writeln('--- 详细 ---');
|
||||
foreach ($stats['details'] as $row) {
|
||||
$tag = !empty($row['success']) ? '[OK]' : '[FAIL]';
|
||||
$line = sprintf(
|
||||
'%s order_id=%s order_no=%s app_order_no=%s tn=%s state=%s traces=+%s source=%s msg=%s',
|
||||
$tag,
|
||||
$row['order_id'] ?? '',
|
||||
$row['order_no'] ?? '',
|
||||
$row['app_order_no'] ?? '',
|
||||
$row['tracking_number'] ?? '',
|
||||
$row['state'] ?? '',
|
||||
$row['traces'] ?? 0,
|
||||
$row['source'] ?? '',
|
||||
$row['message'] ?? ''
|
||||
);
|
||||
$output->writeln($line);
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln('同步完成');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln('总数:' . $stats['total']);
|
||||
if (isset($stats['skipped'])) {
|
||||
$output->writeln('跳过:' . (int) $stats['skipped'] . '(已完成/已签收)');
|
||||
}
|
||||
$output->writeln('成功:' . $stats['success']);
|
||||
$output->writeln('失败:' . $stats['failed']);
|
||||
$output->writeln('医助已移除(甘草同步+指派日志):' . (int) ($stats['assistant_cleared'] ?? 0));
|
||||
$output->writeln('医助已移除(履约已发货核对):' . (int) ($stats['reconcile_cleared'] ?? 0) . '(扫描 ' . (int) ($stats['reconcile_scanned'] ?? 0) . ' 单)');
|
||||
$lines = array_merge($stats['assistant_lines'] ?? [], $stats['reconcile_lines'] ?? []);
|
||||
if ($lines === []) {
|
||||
$output->writeln('医助明细:(无)');
|
||||
} else {
|
||||
$output->writeln('医助明细:');
|
||||
foreach ($lines as $line) {
|
||||
$output->writeln(' ' . $line);
|
||||
}
|
||||
}
|
||||
$output->writeln('耗时:' . $duration . 's');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int, order_no:string}|null
|
||||
*/
|
||||
private static function resolvePrescriptionOrderId(string $raw): ?array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$q = PrescriptionOrder::whereNull('delete_time');
|
||||
if (preg_match('/^\d+$/', $raw) === 1) {
|
||||
$id = (int) $raw;
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$row = (clone $q)->where('id', $id)->field('id,order_no')->find();
|
||||
} else {
|
||||
$row = (clone $q)->where('order_no', $raw)->field('id,order_no')->find();
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'order_no' => (string) $row->order_no,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 将 tcm_diagnosis 中的 tongue_images / report_files 迁移到 zyt_doctor_note 表
|
||||
*
|
||||
* 使用方法:
|
||||
* php think migrate:images-to-doctor-note
|
||||
* php think migrate:images-to-doctor-note --dry-run # 仅统计不实际写入
|
||||
*/
|
||||
class MigrateImagesToDoctorNote extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('migrate:images-to-doctor-note')
|
||||
->addOption('dry-run', null, \think\console\input\Option::VALUE_NONE, '仅统计,不实际写入')
|
||||
->setDescription('迁移诊单舌苔照片/检查报告到医生备注表');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
|
||||
$output->writeln('开始扫描 tcm_diagnosis 中有图片数据的记录...');
|
||||
|
||||
$query = Db::name('tcm_diagnosis')
|
||||
->whereNull('delete_time')
|
||||
->where(function ($q) {
|
||||
$q->whereRaw("tongue_images IS NOT NULL AND tongue_images != '' AND tongue_images != '[]'")
|
||||
->whereOr(function ($q2) {
|
||||
$q2->whereRaw("report_files IS NOT NULL AND report_files != '' AND report_files != '[]'");
|
||||
});
|
||||
})
|
||||
->field('id, patient_id, create_time, tongue_images, report_files');
|
||||
|
||||
$total = $query->count();
|
||||
$output->writeln("找到 {$total} 条需要迁移的诊单记录");
|
||||
|
||||
if ($total === 0) {
|
||||
$output->writeln('<info>无需迁移</info>');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<comment>[dry-run] 不执行实际写入</comment>');
|
||||
return;
|
||||
}
|
||||
|
||||
$migrated = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
$query->chunk(100, function ($rows) use ($output, &$migrated, &$skipped, &$errors) {
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$diagnosisId = (int) $row['id'];
|
||||
$noteDate = $row['create_time'] > 0
|
||||
? date('Y-m-d', (int) $row['create_time'])
|
||||
: date('Y-m-d');
|
||||
|
||||
$tongueImages = array_map([$this, 'toRelativePath'], $this->parseJson($row['tongue_images']));
|
||||
$reportFiles = array_map([$this, 'toRelativePath'], $this->parseJson($row['report_files']));
|
||||
$tongueImages = array_values(array_filter($tongueImages));
|
||||
$reportFiles = array_values(array_filter($reportFiles));
|
||||
|
||||
if (empty($tongueImages) && empty($reportFiles)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = Db::name('doctor_note')
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('note_date', $noteDate)
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
|
||||
if ($existing) {
|
||||
$data = [];
|
||||
if (!empty($tongueImages)) {
|
||||
$prev = $this->parseJson($existing['tongue_images']);
|
||||
$merged = array_values(array_unique(array_merge($prev, $tongueImages)));
|
||||
$data['tongue_images'] = json_encode($merged, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if (!empty($reportFiles)) {
|
||||
$prev = $this->parseJson($existing['report_files']);
|
||||
$merged = array_values(array_unique(array_merge($prev, $reportFiles)));
|
||||
$data['report_files'] = json_encode($merged, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if (!empty($data)) {
|
||||
$data['update_time'] = time();
|
||||
Db::name('doctor_note')->where('id', $existing['id'])->update($data);
|
||||
}
|
||||
} else {
|
||||
Db::name('doctor_note')->insert([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'doctor_id' => 0,
|
||||
'note_date' => $noteDate,
|
||||
'content' => null,
|
||||
'tongue_images' => !empty($tongueImages)
|
||||
? json_encode($tongueImages, JSON_UNESCAPED_UNICODE)
|
||||
: null,
|
||||
'report_files' => !empty($reportFiles)
|
||||
? json_encode($reportFiles, JSON_UNESCAPED_UNICODE)
|
||||
: null,
|
||||
'create_time' => (int) $row['create_time'],
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
$migrated++;
|
||||
} catch (\Exception $e) {
|
||||
$errors++;
|
||||
$output->writeln("<error>诊单 ID={$row['id']} 迁移失败: {$e->getMessage()}</error>");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln('<info>迁移完成</info>');
|
||||
$output->writeln(" 成功: {$migrated}");
|
||||
$output->writeln(" 跳过(空数据): {$skipped}");
|
||||
$output->writeln(" 失败: {$errors}");
|
||||
}
|
||||
|
||||
private function parseJson($value): array
|
||||
{
|
||||
if (empty($value)) return [];
|
||||
if (is_array($value)) return $value;
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function toRelativePath(string $url): string
|
||||
{
|
||||
if (empty($url)) return $url;
|
||||
if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) {
|
||||
return $url;
|
||||
}
|
||||
// 只去掉当前配置的存储域名,其他域名保留完整 URL
|
||||
$default = \app\common\service\ConfigService::get('storage', 'default', 'local');
|
||||
if ($default === 'local') {
|
||||
$domain = request()->domain() . '/';
|
||||
} else {
|
||||
$storage = \app\common\service\ConfigService::get('storage', $default);
|
||||
$domain = $storage ? ($storage['domain'] ?? '') : '';
|
||||
}
|
||||
if ($domain && stripos($url, rtrim($domain, '/')) === 0) {
|
||||
$relative = substr($url, strlen(rtrim($domain, '/')));
|
||||
return ltrim($relative, '/');
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\common\service\qywx\MediaChannelService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一次性把 zyt_qywx_external_contact.follow_users JSON 中的 tags:
|
||||
* 1. 合并去重后回填到 zyt_qywx_external_contact.tags(JSON 字段,详情页用)
|
||||
* 2. 拍平按 (external_userid, follow_user_id, tag_id) 三元组同步到关系表
|
||||
* zyt_qywx_external_contact_tag(用于检索/统计/聚合)
|
||||
*
|
||||
* 使用方法:
|
||||
* php think qywx:backfill-customer-tags
|
||||
* php think qywx:backfill-customer-tags --all (强制刷新所有行,不仅是 tags 为空的)
|
||||
*
|
||||
* 不调企微 API、纯本地解析;新加 tags 字段或关系表后跑一次即可(后续 UPSERT 自动维护)。
|
||||
*/
|
||||
class QywxBackfillCustomerTags extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:backfill-customer-tags')
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
\think\console\input\Option::VALUE_NONE,
|
||||
'强制刷新所有行(默认只处理 tags 为空 / NULL / [] 的行)'
|
||||
)
|
||||
->addOption(
|
||||
'fast',
|
||||
'f',
|
||||
\think\console\input\Option::VALUE_NONE,
|
||||
'快速模式:批量 INSERT IGNORE 关系表,CASE-WHEN 批量 UPDATE tags(首次回填/远程库网络延迟时用)'
|
||||
)
|
||||
->setDescription('回填外部联系人 tags 字段(从本地 follow_users JSON 提取)');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$all = (bool) $input->getOption('all');
|
||||
$fast = (bool) $input->getOption('fast');
|
||||
$startTime = microtime(true);
|
||||
|
||||
if ($fast) {
|
||||
return $this->executeFast($input, $output, $all, $startTime);
|
||||
}
|
||||
|
||||
$output->writeln('开始回填 qywx_external_contact.tags ...');
|
||||
$output->writeln('模式: ' . ($all ? '全量刷新' : '仅刷 tags 为空的行'));
|
||||
|
||||
$query = Db::name('qywx_external_contact')
|
||||
->whereNull('delete_time')
|
||||
->where('follow_users', '<>', '')
|
||||
->where('follow_users', '<>', '[]');
|
||||
|
||||
if (!$all) {
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('tags')
|
||||
->whereOr('tags', '')
|
||||
->whereOr('tags', '[]');
|
||||
});
|
||||
}
|
||||
|
||||
$total = (int) (clone $query)->count();
|
||||
$output->writeln("候选 {$total} 条");
|
||||
|
||||
if ($total === 0) {
|
||||
$output->writeln('无需回填');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
$unchanged = 0;
|
||||
$emptyTags = 0;
|
||||
$relationSynced = 0;
|
||||
|
||||
// 分页处理避免内存爆
|
||||
$pageSize = 500;
|
||||
$lastId = 0;
|
||||
|
||||
while (true) {
|
||||
$rows = (clone $query)
|
||||
->where('id', '>', $lastId)
|
||||
->order('id', 'asc')
|
||||
->limit($pageSize)
|
||||
->field(['id', 'external_userid', 'follow_users', 'tags'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if ($rows === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 拿到本批 id 对应 external_userid,用于同步关系表
|
||||
$idToExt = [];
|
||||
foreach ($rows as $row) {
|
||||
$idToExt[(int) $row['id']] = (string) ($row['external_userid'] ?? '');
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$lastId = (int) $row['id'];
|
||||
$processed++;
|
||||
|
||||
$followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true);
|
||||
if (!is_array($followUsers)) {
|
||||
$followUsers = [];
|
||||
}
|
||||
|
||||
// —— 关系表(每行都同步,不依赖 JSON 字段是否变化;--all 模式下也会全量重写)
|
||||
$extId = $idToExt[$lastId] ?? '';
|
||||
if ($extId !== '') {
|
||||
CustomerLogic::syncContactTagsRelation($extId, $followUsers);
|
||||
$relationSynced++;
|
||||
}
|
||||
|
||||
// —— tags JSON 字段(值未变的跳过 UPDATE,省 IO)
|
||||
$newTags = CustomerLogic::extractFollowUserTags($followUsers);
|
||||
$oldTags = (string) ($row['tags'] ?? '');
|
||||
|
||||
if ($newTags === '[]') {
|
||||
$emptyTags++;
|
||||
}
|
||||
|
||||
if ($newTags === $oldTags) {
|
||||
$unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('qywx_external_contact')
|
||||
->where('id', $lastId)
|
||||
->update([
|
||||
'tags' => $newTags,
|
||||
// 不刷 update_time,避免误触发"最近活跃"类排序
|
||||
]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
if (($processed % 2000) === 0) {
|
||||
$output->writeln(sprintf('进度: %d / %d,已更新 %d', $processed, $total, $updated));
|
||||
}
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln('回填完成');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln("处理: {$processed}");
|
||||
$output->writeln("tags JSON 更新: {$updated}");
|
||||
$output->writeln("tags JSON 未变: {$unchanged}");
|
||||
$output->writeln("空 tags 行数: {$emptyTags} (follow_user 内无任何 tag)");
|
||||
$output->writeln("关系表同步: {$relationSynced} 行");
|
||||
$output->writeln("耗时: {$duration}秒");
|
||||
MediaChannelService::forgetCurrentTagCatalogCache();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速模式:批量 INSERT IGNORE + 批量 CASE-WHEN UPDATE,远程库网络延迟下推荐用此模式。
|
||||
* 注意:不会删除已在关系表中、但当前 follow_users 已不再存在的"过时"关系;首次回填场景安全。
|
||||
*/
|
||||
private function executeFast(Input $input, Output $output, bool $all, float $startTime): int
|
||||
{
|
||||
$output->writeln('开始[快速]回填 qywx_external_contact.tags ...');
|
||||
$output->writeln('模式: ' . ($all ? '全量刷新' : '仅刷 tags 为空的行') . ' + fast');
|
||||
|
||||
$query = Db::name('qywx_external_contact')
|
||||
->whereNull('delete_time')
|
||||
->where('follow_users', '<>', '')
|
||||
->where('follow_users', '<>', '[]');
|
||||
|
||||
if (!$all) {
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('tags')
|
||||
->whereOr('tags', '')
|
||||
->whereOr('tags', '[]');
|
||||
});
|
||||
}
|
||||
|
||||
$total = (int) (clone $query)->count();
|
||||
$output->writeln("候选 {$total} 条");
|
||||
if ($total === 0) {
|
||||
$output->writeln('无需回填');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$tagRowsInserted = 0;
|
||||
$jsonUpdated = 0;
|
||||
$pageSize = 1000;
|
||||
$lastId = 0;
|
||||
$now = time();
|
||||
|
||||
while (true) {
|
||||
$rows = (clone $query)
|
||||
->where('id', '>', $lastId)
|
||||
->order('id', 'asc')
|
||||
->limit($pageSize)
|
||||
->field(['id', 'external_userid', 'follow_users'])
|
||||
->select()
|
||||
->toArray();
|
||||
if ($rows === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$tagBatch = [];
|
||||
$tagJsonByExtId = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$lastId = (int) $row['id'];
|
||||
$processed++;
|
||||
$extId = (string) ($row['external_userid'] ?? '');
|
||||
$followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true);
|
||||
if (!is_array($followUsers)) {
|
||||
$followUsers = [];
|
||||
}
|
||||
|
||||
$tagJsonByExtId[$lastId] = CustomerLogic::extractFollowUserTags($followUsers);
|
||||
|
||||
if ($extId === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($followUsers as $fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$followUserId = mb_substr(trim((string) ($fu['userid'] ?? '')), 0, 64);
|
||||
$tags = $fu['tags'] ?? [];
|
||||
if (!is_array($tags)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($tags as $t) {
|
||||
if (!is_array($t)) {
|
||||
continue;
|
||||
}
|
||||
$tagId = mb_substr(trim((string) ($t['tag_id'] ?? '')), 0, 64);
|
||||
if ($tagId === '') {
|
||||
continue;
|
||||
}
|
||||
$tagBatch[] = [
|
||||
'external_userid' => $extId,
|
||||
'follow_user_id' => $followUserId,
|
||||
'tag_id' => $tagId,
|
||||
'tag_name' => mb_substr((string) ($t['tag_name'] ?? ''), 0, 128),
|
||||
'group_name' => mb_substr((string) ($t['group_name'] ?? ''), 0, 128),
|
||||
'type' => isset($t['type']) ? (int) $t['type'] : 1,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($tagBatch !== []) {
|
||||
$tagRowsInserted += $this->batchInsertIgnoreTags($tagBatch);
|
||||
}
|
||||
if ($tagJsonByExtId !== []) {
|
||||
$jsonUpdated += $this->batchUpdateTagsJson($tagJsonByExtId);
|
||||
}
|
||||
|
||||
$output->writeln(sprintf('进度: %d / %d 关系累计 %d tags JSON 累计 %d', $processed, $total, $tagRowsInserted, $jsonUpdated));
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
$output->writeln('');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln('[快速]回填完成');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln("处理: {$processed}");
|
||||
$output->writeln("关系表 INSERT IGNORE: {$tagRowsInserted}(含可能被忽略的重复行)");
|
||||
$output->writeln("tags JSON 批量 UPDATE: {$jsonUpdated}");
|
||||
$output->writeln("耗时: {$duration}秒");
|
||||
MediaChannelService::forgetCurrentTagCatalogCache();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量 INSERT IGNORE 到关系表。返回受影响(实际新插入)行数。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function batchInsertIgnoreTags(array $rows): int
|
||||
{
|
||||
if ($rows === []) {
|
||||
return 0;
|
||||
}
|
||||
$chunks = array_chunk($rows, 500);
|
||||
$affected = 0;
|
||||
foreach ($chunks as $chunk) {
|
||||
$values = [];
|
||||
$params = [];
|
||||
foreach ($chunk as $r) {
|
||||
$values[] = '(?,?,?,?,?,?,?,?)';
|
||||
$params[] = $r['external_userid'];
|
||||
$params[] = $r['follow_user_id'];
|
||||
$params[] = $r['tag_id'];
|
||||
$params[] = $r['tag_name'];
|
||||
$params[] = $r['group_name'];
|
||||
$params[] = $r['type'];
|
||||
$params[] = $r['create_time'];
|
||||
$params[] = $r['update_time'];
|
||||
}
|
||||
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
|
||||
$sql = "INSERT IGNORE INTO {$prefix}qywx_external_contact_tag "
|
||||
. '(external_userid, follow_user_id, tag_id, tag_name, group_name, type, create_time, update_time) VALUES '
|
||||
. implode(',', $values);
|
||||
Db::execute($sql, $params);
|
||||
$affected += count($chunk);
|
||||
}
|
||||
|
||||
return $affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 CASE WHEN id THEN val 一条 SQL 批量 UPDATE tags JSON。
|
||||
*
|
||||
* @param array<int, string> $idToTagsJson
|
||||
*/
|
||||
private function batchUpdateTagsJson(array $idToTagsJson): int
|
||||
{
|
||||
if ($idToTagsJson === []) {
|
||||
return 0;
|
||||
}
|
||||
$chunks = array_chunk($idToTagsJson, 500, true);
|
||||
$affected = 0;
|
||||
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
|
||||
foreach ($chunks as $chunk) {
|
||||
$cases = [];
|
||||
$ids = [];
|
||||
$params = [];
|
||||
foreach ($chunk as $id => $tagsJson) {
|
||||
$cases[] = 'WHEN ? THEN ?';
|
||||
$params[] = $id;
|
||||
$params[] = $tagsJson;
|
||||
$ids[] = (int) $id;
|
||||
}
|
||||
$idList = implode(',', $ids);
|
||||
$sql = "UPDATE {$prefix}qywx_external_contact SET tags = CASE id "
|
||||
. implode(' ', $cases)
|
||||
. " END WHERE id IN ({$idList})";
|
||||
Db::execute($sql, $params);
|
||||
$affected += count($chunk);
|
||||
}
|
||||
|
||||
return $affected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\wechat\WechatWorkService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 检查企业微信API权限
|
||||
*/
|
||||
class QywxCheckPermissions extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:check-permissions')
|
||||
->setDescription('检查企业微信API权限配置');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$output->writeln('开始检查企业微信API权限...');
|
||||
$output->writeln('');
|
||||
|
||||
try {
|
||||
$service = new WechatWorkService();
|
||||
|
||||
// 检查应用权限
|
||||
$output->writeln('1. 检查应用配置...');
|
||||
$permissionCheck = $service->checkAppPermissions();
|
||||
|
||||
if ($permissionCheck['success']) {
|
||||
$output->writeln(' ✓ 应用配置正常');
|
||||
$output->writeln(' 应用ID: ' . ($permissionCheck['data']['agentid'] ?? 'N/A'));
|
||||
$output->writeln(' 应用名称: ' . ($permissionCheck['data']['name'] ?? 'N/A'));
|
||||
} else {
|
||||
$output->writeln(' ✗ 应用配置异常: ' . $permissionCheck['message']);
|
||||
}
|
||||
$output->writeln('');
|
||||
|
||||
// 测试获取部门成员
|
||||
$output->writeln('2. 测试获取部门成员...');
|
||||
$userList = $service->getDepartmentUserList(1, false);
|
||||
if (!empty($userList)) {
|
||||
$output->writeln(' ✓ 成功获取 ' . count($userList) . ' 个成员');
|
||||
$testUser = $userList[0] ?? null;
|
||||
if ($testUser) {
|
||||
$output->writeln(' 测试用户: ' . ($testUser['name'] ?? '') . ' (' . ($testUser['userid'] ?? '') . ')');
|
||||
}
|
||||
} else {
|
||||
$output->writeln(' ✗ 未能获取成员列表');
|
||||
}
|
||||
$output->writeln('');
|
||||
|
||||
// 测试获取客户列表(这里会暴露权限问题)
|
||||
if (!empty($userList)) {
|
||||
$output->writeln('3. 测试获取客户列表权限...');
|
||||
$testUser = $userList[0];
|
||||
$customerList = $service->getExternalContactList($testUser['userid']);
|
||||
|
||||
if ($customerList === false) {
|
||||
$output->writeln(' ✗ 客户联系API权限未开启(错误码48002)');
|
||||
$output->writeln('');
|
||||
$output->writeln('解决方案:');
|
||||
$output->writeln('1. 登录企业微信管理后台');
|
||||
$output->writeln('2. 进入"应用管理"');
|
||||
$output->writeln('3. 找到您的应用');
|
||||
$output->writeln('4. 开启以下权限:');
|
||||
$output->writeln(' - 客户联系 - 获取客户列表');
|
||||
$output->writeln(' - 客户联系 - 获取客户详情');
|
||||
$output->writeln('5. 将服务器IP添加到可信IP白名单');
|
||||
} elseif (is_array($customerList)) {
|
||||
$output->writeln(' ✓ 客户联系API权限正常');
|
||||
$output->writeln(' 该成员的客户数量: ' . count($customerList));
|
||||
} else {
|
||||
$output->writeln(' ? 未知响应类型');
|
||||
}
|
||||
}
|
||||
$output->writeln('');
|
||||
$output->writeln('检查完成!');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('');
|
||||
$output->writeln('✗ 检查过程出错: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
/** 每分钟重试获客助手 message_from_customer/customer_start_chat 回调。 */
|
||||
class QywxRetryCustomerAcquisitionEvents extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:retry-customer-acquisition-events')
|
||||
->setDescription('重试 30 分钟有效期内失败的企业微信获客会话回调');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$result = (new QywxCustomerAcquisitionCustomerService())->retryPending(100);
|
||||
$output->writeln(sprintf(
|
||||
'QYWX_CUSTOMER_ACQUISITION_RETRY selected=%d success=%d failed=%d expired=%d',
|
||||
$result['selected'],
|
||||
$result['success'],
|
||||
$result['failed'],
|
||||
$result['expired']
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\qywx\MediaChannelService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
class QywxScanMediaChannel extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:scan-media-channel')
|
||||
->addOption('batch', 'b', \think\console\input\Option::VALUE_OPTIONAL, '每批扫描客户数', 200)
|
||||
->setDescription('扫描企微客户 follow_users 并记录渠道标签源');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$batchSize = max(1, (int) $input->getOption('batch'));
|
||||
$output->writeln('开始扫描企微客户渠道标签...');
|
||||
|
||||
$result = MediaChannelService::scanFromContacts($batchSize);
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'扫描完成:客户 %d,发现渠道标签 %d,写入/更新 %d。',
|
||||
(int) ($result['scanned_contacts'] ?? 0),
|
||||
(int) ($result['discovered_tags'] ?? 0),
|
||||
(int) ($result['inserted_or_updated'] ?? 0)
|
||||
));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信客户同步定时任务
|
||||
*
|
||||
* 使用方法:
|
||||
* php think qywx:sync-customer
|
||||
* php think qywx:sync-customer --force --today # 仅落库「今日首次加为外部联系人」的客户(app.default_timezone)
|
||||
* php think qywx:sync-customer --force --today --today-any-follow # 任一条跟进在今天即落库(旧逻辑,命中多)
|
||||
*
|
||||
* 说明:企微无「只拉今日客户」接口,--today 仍需翻完各成员 batch 分页;开启后会流式筛候选 id,只对候选 get+写库(省内存与 UPSERT)。增量实时请配客户联系回调。
|
||||
*
|
||||
* 配置crontab(每小时执行一次):
|
||||
* 0 * * * * cd /path/to/project && php think qywx:sync-customer >> /dev/null 2>&1
|
||||
*/
|
||||
class QywxSyncCustomer extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:sync-customer')
|
||||
->addOption('force', 'f', \think\console\input\Option::VALUE_NONE, '强制同步,忽略自动同步设置')
|
||||
->addOption(
|
||||
'today',
|
||||
't',
|
||||
\think\console\input\Option::VALUE_NONE,
|
||||
'仅落库「企微首次添加时间」在今日的客户(min(createtime),时区见 app.default_timezone);仍会拉全量列表与详情'
|
||||
)
|
||||
->addOption(
|
||||
'today-any-follow',
|
||||
null,
|
||||
\think\console\input\Option::VALUE_NONE,
|
||||
'需与 --today 同时使用:任一条跟进 createtime 在今日即落库(旧行为,容易大量命中)'
|
||||
)
|
||||
->setDescription('同步企业微信客户数据');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$output->writeln('开始同步企业微信客户...');
|
||||
|
||||
try {
|
||||
$force = $input->getOption('force');
|
||||
|
||||
if (!$force) {
|
||||
// 检查是否开启自动同步
|
||||
$settings = CustomerLogic::getSyncSettings();
|
||||
if (!($settings['auto_sync'] ?? false)) {
|
||||
$output->writeln('自动同步未开启,跳过(使用 --force 强制同步)');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 检查距离上次同步是否超过间隔时间
|
||||
$lastSyncTime = $settings['last_sync_time'] ?? 0;
|
||||
$interval = $settings['interval'] ?? 3600;
|
||||
$now = time();
|
||||
|
||||
if ($now - $lastSyncTime < $interval) {
|
||||
$output->writeln('距离上次同步时间不足,跳过(使用 --force 强制同步)');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行同步
|
||||
$syncOptions = [];
|
||||
if ($input->getOption('today')) {
|
||||
[$syncOptions['follow_createtime_from'], $syncOptions['follow_createtime_to']] = CustomerLogic::todayCreatetimeWindowBounds();
|
||||
$syncOptions['follow_createtime_mode'] = $input->getOption('today-any-follow') ? 'any' : 'first';
|
||||
$tz = (string) config('app.default_timezone', 'Asia/Shanghai');
|
||||
$output->writeln(sprintf(
|
||||
'今日窗口(%s): %s ~ %s | 模式: %s',
|
||||
$tz,
|
||||
date('Y-m-d H:i:s', $syncOptions['follow_createtime_from']),
|
||||
date('Y-m-d H:i:s', $syncOptions['follow_createtime_to']),
|
||||
$syncOptions['follow_createtime_mode'] === 'any' ? '任一条跟进在今天' : '仅首次添加在今天(今日新客)'
|
||||
));
|
||||
}
|
||||
$result = CustomerLogic::syncCustomers($syncOptions);
|
||||
if ($result === false) {
|
||||
$error = CustomerLogic::getError();
|
||||
$output->writeln('同步失败: ' . $error);
|
||||
Log::error('企业微信客户同步失败: ' . $error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('同步成功!');
|
||||
$output->writeln('同步数量: ' . ($result['sync_count'] ?? 0));
|
||||
$output->writeln('新增数量: ' . ($result['new_count'] ?? 0));
|
||||
$output->writeln('更新数量: ' . ($result['update_count'] ?? 0));
|
||||
$skipped = (int) ($result['skipped_count'] ?? 0);
|
||||
if ($skipped > 0) {
|
||||
$output->writeln('跳过数量(非指定日期内新建跟进): ' . $skipped);
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('同步异常: ' . $e->getMessage());
|
||||
Log::error('企业微信客户同步异常: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\wechat\QywxMsgArchiveService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信会话内容存档同步
|
||||
*
|
||||
* 使用方法:
|
||||
* php think qywx:sync-msg-archive # 只拉消息
|
||||
* php think qywx:sync-msg-archive --download # 拉完后同时下载 pending 媒体
|
||||
* php think qywx:sync-msg-archive --only-media # 仅下载已登记的 pending 媒体
|
||||
*
|
||||
* crontab 建议:每 30 秒拉一次(用 sleep 模拟半分钟级)
|
||||
* * * * * * cd /path/to/project && php think qywx:sync-msg-archive --download >> /dev/null 2>&1
|
||||
* * * * * * cd /path/to/project && sleep 30 && php think qywx:sync-msg-archive --download >> /dev/null 2>&1
|
||||
*
|
||||
* 并发控制:通过文件锁,同一时刻只会跑一个,多开直接退出。
|
||||
*/
|
||||
class QywxSyncMsgArchive extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:sync-msg-archive')
|
||||
->addOption('download', 'd', Option::VALUE_NONE, '拉完消息后顺便下载 pending 媒体')
|
||||
->addOption('only-media', null, Option::VALUE_NONE, '仅下载已登记的 pending 媒体')
|
||||
->addOption('max-batches', 'b', Option::VALUE_REQUIRED, '单次运行最多拉多少批 (默认 20)', 20)
|
||||
->addOption('max-media', 'm', Option::VALUE_REQUIRED, '单次最多下载多少条媒体 (默认 200)', 200)
|
||||
->setDescription('拉取企业微信会话内容存档');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$lockFile = runtime_path() . 'lock' . DIRECTORY_SEPARATOR . 'qywx_msg_archive.lock';
|
||||
$lockDir = dirname($lockFile);
|
||||
if (!is_dir($lockDir)) {
|
||||
@mkdir($lockDir, 0755, true);
|
||||
}
|
||||
$fp = @fopen($lockFile, 'c');
|
||||
if ($fp === false) {
|
||||
$output->writeln('<error>无法创建锁文件: ' . $lockFile . '</error>');
|
||||
|
||||
return 1;
|
||||
}
|
||||
if (!flock($fp, LOCK_EX | LOCK_NB)) {
|
||||
fclose($fp);
|
||||
$output->writeln('另一个会话存档同步进程正在运行,跳过本次。');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$onlyMedia = (bool) $input->getOption('only-media');
|
||||
$download = (bool) $input->getOption('download');
|
||||
$maxBatches = max(1, (int) $input->getOption('max-batches'));
|
||||
$maxMedia = max(1, (int) $input->getOption('max-media'));
|
||||
|
||||
if (!$onlyMedia) {
|
||||
$pull = QywxMsgArchiveService::pullLoop($maxBatches);
|
||||
if (!$pull['enabled']) {
|
||||
$output->writeln('<comment>会话存档未启用或 SDK 不可用:' . implode('; ', $pull['errors']) . '</comment>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
$output->writeln(sprintf(
|
||||
'消息拉取:batches=%d pulled=%d inserted=%d updated=%d media_pending=%d last_seq=%d',
|
||||
$pull['batches'],
|
||||
$pull['pulled'],
|
||||
$pull['inserted'],
|
||||
$pull['updated'],
|
||||
$pull['media_pending'],
|
||||
$pull['last_seq']
|
||||
));
|
||||
if (!empty($pull['errors'])) {
|
||||
foreach ($pull['errors'] as $err) {
|
||||
Log::warning('qywx:sync-msg-archive pull error: ' . $err);
|
||||
$output->writeln('<comment>error: ' . $err . '</comment>');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($onlyMedia || $download) {
|
||||
$media = QywxMsgArchiveService::downloadPendingMedia($maxMedia);
|
||||
if (!$media['enabled']) {
|
||||
$output->writeln('<comment>媒体下载-SDK 未启用</comment>');
|
||||
} else {
|
||||
$output->writeln(sprintf(
|
||||
'媒体下载:ok=%d failed=%d skipped=%d',
|
||||
$media['ok'],
|
||||
$media['failed'],
|
||||
$media['skipped']
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>同步异常: ' . $e->getMessage() . '</error>');
|
||||
Log::error('qywx:sync-msg-archive 异常: ' . $e->getMessage());
|
||||
|
||||
return 1;
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 同步现有订单快递单号到物流追踪表
|
||||
*
|
||||
* 使用方法:
|
||||
* php think express:sync
|
||||
*/
|
||||
class SyncTrackingNumbers extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('express:sync')
|
||||
->setDescription('同步现有订单快递单号到物流追踪表');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$output->writeln('开始同步现有订单快递单号...');
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// 终态订单不再触发查件:已完成(3)/已取消(4)/已签收(6)/暂不制药(8)/拒收(9)/退款(10)/保留药方(11)/制药缓发(12)
|
||||
$terminalFulfillmentStatus = [3, 4, 6, 8, 9, 10, 11, 12];
|
||||
|
||||
// 查询所有有快递单号、未结案、且未上传甘草的订单
|
||||
// 已上传甘草(gancao_reciperl_order_no 非空)的物流由甘草侧 GancaoLogisticsRouteService 拉取,不重复走快递100
|
||||
$orders = Db::name('tcm_prescription_order')
|
||||
->where('tracking_number', '<>', '')
|
||||
->whereNull('delete_time')
|
||||
->whereNotIn('fulfillment_status', $terminalFulfillmentStatus)
|
||||
->whereRaw("TRIM(COALESCE(gancao_reciperl_order_no, '')) = ''")
|
||||
->field([
|
||||
'id',
|
||||
'tracking_number',
|
||||
'express_company',
|
||||
'recipient_name',
|
||||
'recipient_phone',
|
||||
'shipping_address',
|
||||
])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$total = count($orders);
|
||||
$success = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
$output->writeln("找到 {$total} 个有快递单号、未结案、未上传甘草的订单(已跳过已完成/已取消/已签收等终态及甘草已托管订单)");
|
||||
|
||||
foreach ($orders as $order) {
|
||||
try {
|
||||
// 检查是否已存在
|
||||
$exists = Db::name('express_tracking')
|
||||
->where('tracking_number', $order['tracking_number'])
|
||||
->whereNull('delete_time')
|
||||
->count();
|
||||
|
||||
if ($exists > 0) {
|
||||
$skipped++;
|
||||
$output->writeln("跳过: {$order['tracking_number']} (已存在)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建追踪记录
|
||||
$result = ExpressTrackingService::createOrUpdate([
|
||||
'order_id' => $order['id'],
|
||||
'order_type' => 'prescription',
|
||||
'tracking_number' => $order['tracking_number'],
|
||||
'express_company' => $order['express_company'] ?: 'auto',
|
||||
'recipient_phone' => $order['recipient_phone'],
|
||||
'recipient_name' => $order['recipient_name'],
|
||||
'recipient_address' => $order['shipping_address'],
|
||||
]);
|
||||
|
||||
if ($result) {
|
||||
$success++;
|
||||
$output->writeln("成功: {$order['tracking_number']}");
|
||||
} else {
|
||||
$failed++;
|
||||
$output->writeln("失败: {$order['tracking_number']}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$output->error("错误: {$order['tracking_number']} - {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln('同步完成!');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln("总数: {$total}");
|
||||
$output->writeln("成功: {$success}");
|
||||
$output->writeln("跳过: {$skipped}");
|
||||
$output->writeln("失败: {$failed}");
|
||||
$output->writeln("耗时: {$duration}秒");
|
||||
$output->writeln('');
|
||||
$output->writeln('现在可以运行定时任务测试:');
|
||||
$output->writeln(' php think express:auto-update');
|
||||
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
$output->error("同步失败: " . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 历史 internal_cost 回填:对每条业务订单调用甘草预下单(CTM_PREVIEW),
|
||||
* 将返回 fee 的「药材成本 + 制作费 + 物流费」合计写入 zyt_tcm_prescription_order.internal_cost。
|
||||
*
|
||||
* php think tcm:backfill-internal-cost --dry-run
|
||||
* php think tcm:backfill-internal-cost --limit=30 --sleep-ms=400
|
||||
* php think tcm:backfill-internal-cost --order-id=123
|
||||
* php think tcm:backfill-internal-cost --force
|
||||
* php think tcm:backfill-internal-cost --null-or-zero --min-id=1000
|
||||
* php think tcm:backfill-internal-cost --fulfillment-status=5,6,3
|
||||
*
|
||||
* 履约状态代码(fulfillment_status):1待双审通过 2待发货 3已完成 4已取消 5已发货 6已签收
|
||||
* 7进行中 8暂不制药 9拒收 10退款 11保留药方 12制药缓发
|
||||
*
|
||||
* 说明:使用 root 上下文仅用于绕过后台「谁能看哪张处方/订单」校验;不落管理员登录态。
|
||||
* 甘草未配置、药材无法匹配、规则拦截、缺药等会跳过并在末尾汇总。
|
||||
*/
|
||||
class TcmBackfillPrescriptionOrderInternalCost extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('tcm:backfill-internal-cost')
|
||||
->setDescription('批量用甘草预报价回填处方业务订单 internal_cost')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '只演练不写库')
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '最多处理条数', 100)
|
||||
->addOption('order-id', null, Option::VALUE_OPTIONAL, '仅处理指定 prescription_order.id', null)
|
||||
->addOption('force', 'f', Option::VALUE_NONE, '覆盖已有 internal_cost(默认只填 NULL)')
|
||||
->addOption('null-or-zero', null, Option::VALUE_NONE, '与默认一致且同时处理 internal_cost=0 的行(仍可用 --force 覆盖任意值)')
|
||||
->addOption('min-id', null, Option::VALUE_OPTIONAL, '仅 id>=该值', null)
|
||||
->addOption('max-id', null, Option::VALUE_OPTIONAL, '仅 id<=该值', null)
|
||||
->addOption(
|
||||
'fulfillment-status',
|
||||
null,
|
||||
Option::VALUE_OPTIONAL,
|
||||
'仅处理指定履约状态,逗号分隔数字,如 5,6,3=已发货/已签收/已完成;不传则不限',
|
||||
null
|
||||
)
|
||||
->addOption('sleep-ms', null, Option::VALUE_OPTIONAL, '每条成功调用后休眠毫秒,减轻开放平台压力', 250)
|
||||
->addOption('detail', 'd', Option::VALUE_NONE, '打印每条明细');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{admin_id:int, root:int, name:string, role_id:int[]}
|
||||
*/
|
||||
private static function cliAdminInfo(): array
|
||||
{
|
||||
return [
|
||||
'admin_id' => 0,
|
||||
'root' => 1,
|
||||
'name' => 'CLI-internal-cost',
|
||||
'role_id' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fee
|
||||
*/
|
||||
private static function totalFeeFromPreview(array $fee): float
|
||||
{
|
||||
$m = (float) ($fee['m_cost'] ?? 0);
|
||||
$p = (float) ($fee['proces_cost'] ?? 0);
|
||||
$l = (float) ($fee['lis_cost'] ?? 0);
|
||||
|
||||
return round($m + $p + $l, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CLI 传入的履约状态列表(1–12,与 PrescriptionOrderLogic::fulfillmentStatusLabel 一致)
|
||||
*
|
||||
* @return int[] 去重后的状态码;$raw 非空但解析不到合法值时返回 null 表示调用方应报错
|
||||
*/
|
||||
private static function parseFulfillmentStatusOption(?string $raw): ?array
|
||||
{
|
||||
if ($raw === null) {
|
||||
return [];
|
||||
}
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$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');
|
||||
$nullOrZero = (bool) $input->getOption('null-or-zero');
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$sleepMs = max(0, (int) $input->getOption('sleep-ms'));
|
||||
$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');
|
||||
$fsRaw = $fsRaw !== null ? (string) $fsRaw : null;
|
||||
$fulfillmentStatuses = self::parseFulfillmentStatusOption($fsRaw);
|
||||
if ($fulfillmentStatuses === null) {
|
||||
$output->error('--fulfillment-status 格式无效,请传入 1–12 的数字,英文逗号分隔,例如:5,6,3');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$adminInfo = self::cliAdminInfo();
|
||||
$adminId = 0;
|
||||
|
||||
$output->writeln('========================================');
|
||||
$output->writeln('批量回填 internal_cost(甘草 CTM_PREVIEW)');
|
||||
$output->writeln('========================================');
|
||||
|
||||
if (!GancaoScmRecipelService::isConfigured()) {
|
||||
$output->error('甘草 SCM 未配置:' . GancaoScmRecipelService::whyNotConfigured());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$q = PrescriptionOrder::whereNull('delete_time')->where('prescription_id', '>', 0)->order('id', 'asc');
|
||||
|
||||
if ($fulfillmentStatuses !== []) {
|
||||
$q->whereIn('fulfillment_status', $fulfillmentStatuses);
|
||||
}
|
||||
|
||||
if ($onlyOrderId !== null && $onlyOrderId > 0) {
|
||||
$q->where('id', $onlyOrderId);
|
||||
} else {
|
||||
if (!$force) {
|
||||
if ($nullOrZero) {
|
||||
$q->whereRaw('(internal_cost IS NULL OR internal_cost = 0)');
|
||||
} else {
|
||||
$q->whereNull('internal_cost');
|
||||
}
|
||||
}
|
||||
if ($minId !== null && $minId > 0) {
|
||||
$q->where('id', '>=', $minId);
|
||||
}
|
||||
if ($maxId !== null && $maxId > 0) {
|
||||
$q->where('id', '<=', $maxId);
|
||||
}
|
||||
$q->limit($limit);
|
||||
}
|
||||
|
||||
$rows = $q->field(['id', 'order_no', 'dose_count', 'medication_days', 'internal_cost', 'fulfillment_status'])->select()->toArray();
|
||||
if ($rows === []) {
|
||||
$output->warning('没有符合条件的订单。');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$fsLabel = '';
|
||||
if ($fulfillmentStatuses !== []) {
|
||||
$parts = [];
|
||||
foreach ($fulfillmentStatuses as $code) {
|
||||
$parts[] = (string) $code . '=' . PrescriptionOrderLogic::fulfillmentStatusLabel((int) $code);
|
||||
}
|
||||
$fsLabel = ' fulfillment-status=[' . implode(',', $parts) . ']';
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'待处理 %d 条(dry-run=%s force=%s sleep-ms=%d%s)',
|
||||
count($rows),
|
||||
$dryRun ? 'yes' : 'no',
|
||||
$force ? 'yes' : 'no',
|
||||
$sleepMs,
|
||||
$fsLabel
|
||||
));
|
||||
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
$skipped = 0;
|
||||
$reasons = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$force && !$nullOrZero && $row['internal_cost'] !== null && $row['internal_cost'] !== '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$params = [];
|
||||
$dc = (int) ($row['dose_count'] ?? 0);
|
||||
if ($dc > 0) {
|
||||
$params['dose_count'] = $dc;
|
||||
}
|
||||
$md = $row['medication_days'] ?? null;
|
||||
if ($md !== null && $md !== '' && (int) $md > 0) {
|
||||
$params['medication_days'] = (int) $md;
|
||||
}
|
||||
|
||||
$ret = PrescriptionOrderLogic::previewGancaoRecipel($id, $adminId, $adminInfo, $params);
|
||||
if ($ret === false) {
|
||||
$fail++;
|
||||
$err = PrescriptionOrderLogic::getError();
|
||||
$reasons[$err] = ($reasons[$err] ?? 0) + 1;
|
||||
if ($verbose) {
|
||||
$output->writeln("<error>#{$id} 失败:{$err}</error>");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fee = is_array($ret['fee'] ?? null) ? $ret['fee'] : [];
|
||||
$total = self::totalFeeFromPreview($fee);
|
||||
|
||||
if ($verbose) {
|
||||
$ono = (string) ($row['order_no'] ?? '');
|
||||
$fs = (int) ($row['fulfillment_status'] ?? 0);
|
||||
$fst = PrescriptionOrderLogic::fulfillmentStatusLabel($fs);
|
||||
$output->writeln(sprintf('#%d %s [%s] → internal_cost=%.2f', $id, $ono, $fst, $total));
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
PrescriptionOrder::where('id', $id)->whereNull('delete_time')->update([
|
||||
'internal_cost' => $total,
|
||||
]);
|
||||
|
||||
$log = new PrescriptionOrderLog();
|
||||
$log->prescription_order_id = $id;
|
||||
$log->admin_id = 0;
|
||||
$log->admin_name = 'CLI';
|
||||
$log->action = 'cli_backfill_internal_cost';
|
||||
$log->summary = mb_substr(sprintf('甘草预报价回填 internal_cost=%.2f', $total), 0, 500);
|
||||
$log->create_time = time();
|
||||
try {
|
||||
$log->save();
|
||||
} catch (\Throwable $e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
$ok++;
|
||||
if ($sleepMs > 0 && !$dryRun) {
|
||||
usleep($sleepMs * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln(sprintf('完成:成功 %d,失败 %d,跳过 %d', $ok, $fail, $skipped));
|
||||
if ($reasons !== []) {
|
||||
$output->writeln('失败原因统计:');
|
||||
foreach ($reasons as $msg => $cnt) {
|
||||
$output->writeln(' [' . $cnt . 'x] ' . $msg);
|
||||
}
|
||||
}
|
||||
|
||||
return $fail > 0 ? 2 : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?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,无需修正 %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("<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user