Merge remote-tracking branch 'origin/master' into feature/tongji-endless-game

# Conflicts:
#	TUICallKit-Vue3/tongji/endless-game/README.md
#	TUICallKit-Vue3/tongji/endless-game/index.vue
#	TUICallKit-Vue3/vite.config.ts
This commit is contained in:
大哥大哥的大哥哥
2026-07-18 11:24:59 +08:00
338 changed files with 1029 additions and 681 deletions
@@ -6,11 +6,13 @@ namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\stats\AutoAssignLogLists;
use app\adminapi\logic\stats\AutoAssignLogLogic;
/**
* 待分配诊单自动指派日志
*
* - GET stats.autoAssignLog/lists 日志列表(每条待指派诊单一行:分配结果 + 原因)
* - GET stats.autoAssignLog/lists 日志列表
* - POST stats.autoAssignLog/rollback 批量回退已自动分配的医助
*/
class AutoAssignLogController extends BaseAdminController
{
@@ -18,4 +20,25 @@ class AutoAssignLogController extends BaseAdminController
{
return $this->dataLists(new AutoAssignLogLists());
}
public function rollback()
{
$ids = $this->request->post('ids', []);
if (!is_array($ids)) {
$ids = [$ids];
}
$result = AutoAssignLogLogic::rollback($ids, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AutoAssignLogLogic::getError());
}
$msg = sprintf('回退成功 %d 条', (int) ($result['success'] ?? 0));
$failed = (int) ($result['failed'] ?? 0);
if ($failed > 0) {
$msg .= sprintf(',跳过/失败 %d 条', $failed);
}
// show=0:由前端按 success/failed 明细提示,避免与部分失败警告重复
return $this->success($msg, $result, 1, 0);
}
}
@@ -12,7 +12,7 @@ use think\facade\Db;
* 待分配诊单自动指派日志列表
*
* 数据来源:tcm_diagnosis_auto_assign_log(定时命令 tcm:auto-assign-pending 写入)
* 筛选:run_date(执行日期)/ action1=已分配 0=未分配)/ assistant_id / keyword(患者姓名、手机号、医助姓名,纯数字兼容诊单ID)
* 筛选:run_date / create_time 区间 / action / keyword / rollback 状态
*/
class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterface
{
@@ -49,6 +49,17 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
$rbTs = (int) ($row['rollback_time'] ?? 0);
$row['rollback_time_text'] = $rbTs > 0 ? date('Y-m-d H:i:s', $rbTs) : '';
$row['is_rollback'] = $rbTs > 0 ? 1 : 0;
$row['can_rollback'] = ((int) ($row['action'] ?? 0) === 1 && $rbTs === 0) ? 1 : 0;
if ($rbTs > 0) {
$row['rollback_status_text'] = '已回退'
. (($row['rollback_admin_name'] ?? '') !== '' ? (' · ' . $row['rollback_admin_name']) : '');
} else {
$row['rollback_status_text'] = ((int) ($row['action'] ?? 0) === 1) ? '可回退' : '—';
}
}
unset($row);
@@ -91,6 +102,46 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
$query->where('run_date', '<=', $endDate);
}
// 记录时间(create_time)区间:支持 Y-m-d 或 Y-m-d H:i:s
$startTs = $this->parseTimeParam((string) ($this->params['start_time'] ?? ''), false);
if ($startTs > 0) {
$query->where('create_time', '>=', $startTs);
}
$endTs = $this->parseTimeParam((string) ($this->params['end_time'] ?? ''), true);
if ($endTs > 0) {
$query->where('create_time', '<=', $endTs);
}
$rollback = trim((string) ($this->params['is_rollback'] ?? ''));
if ($rollback === '1') {
$query->where('rollback_time', '>', 0);
} elseif ($rollback === '0') {
$query->where('rollback_time', '=', 0);
}
return $query;
}
/**
* @param bool $endOfDay 仅日期时是否取当天结束
*/
private function parseTimeParam(string $raw, bool $endOfDay): int
{
$raw = trim($raw);
if ($raw === '') {
return 0;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) {
return (int) strtotime($raw . ($endOfDay ? ' 23:59:59' : ' 00:00:00'));
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $raw) === 1) {
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $raw) === 1) {
$raw .= $endOfDay ? ':59' : ':00';
}
return (int) strtotime($raw);
}
return 0;
}
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use think\facade\Db;
use think\facade\Log;
/**
* 待分配诊单自动指派日志:回退已自动分配的医助
*/
class AutoAssignLogLogic extends BaseLogic
{
/**
* 批量回退:将诊单医助撤回到自动分配前的原医助(指派日志 from_assistant_id),并标记自动分配日志已回退。
*
* @param list<int|string> $ids 自动分配日志 id
* @return array{success:int,failed:int,messages:list<string>}|false
*/
public static function rollback(array $ids, int $adminId, array $adminInfo = []): array|false
{
$idList = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id) => $id > 0)));
if ($idList === []) {
self::setError('请选择要回退的记录');
return false;
}
$adminName = (string) ($adminInfo['name'] ?? '');
$adminAccount = (string) ($adminInfo['account'] ?? '');
$req = request();
$ip = (string) ($req->ip() ?? '');
$now = time();
$success = 0;
$failed = 0;
$messages = [];
foreach ($idList as $logId) {
try {
$ret = self::rollbackOne($logId, $adminId, $adminName, $adminAccount, $ip, $now);
if ($ret === true) {
$success++;
} else {
$failed++;
$messages[] = (string) $ret;
}
} catch (\Throwable $e) {
$failed++;
$messages[] = sprintf('日志#%d%s', $logId, $e->getMessage());
Log::warning('auto assign rollback failed: ' . $e->getMessage(), ['log_id' => $logId]);
}
}
if ($success === 0 && $failed > 0) {
self::setError($messages[0] ?? '回退失败');
return false;
}
return [
'success' => $success,
'failed' => $failed,
'messages' => $messages,
];
}
/**
* @return true|string true=成功,string=失败原因
*/
private static function rollbackOne(
int $logId,
int $adminId,
string $adminName,
string $adminAccount,
string $ip,
int $now
): bool|string {
Db::startTrans();
try {
$log = Db::name('tcm_diagnosis_auto_assign_log')
->where('id', $logId)
->lock(true)
->find();
if ($log === null || $log === []) {
Db::rollback();
return sprintf('日志#%d:记录不存在', $logId);
}
if ((int) ($log['action'] ?? 0) !== 1) {
Db::rollback();
return sprintf('日志#%d:仅「已分配」记录可回退', $logId);
}
if ((int) ($log['rollback_time'] ?? 0) > 0) {
Db::rollback();
return sprintf('日志#%d:已回退,勿重复操作', $logId);
}
$diagnosisId = (int) ($log['diagnosis_id'] ?? 0);
$assignedAssistantId = (int) ($log['assistant_id'] ?? 0);
if ($diagnosisId <= 0 || $assignedAssistantId <= 0) {
Db::rollback();
return sprintf('日志#%d:数据不完整,无法回退', $logId);
}
$diag = Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->lock(true)
->field(['id', 'assistant_id'])
->find();
if ($diag === null || $diag === []) {
Db::rollback();
return sprintf('日志#%d:诊单#%d 不存在', $logId, $diagnosisId);
}
$currentAssistantId = (int) ($diag['assistant_id'] ?? 0);
if ($currentAssistantId !== $assignedAssistantId) {
Db::rollback();
return sprintf(
'日志#%d:诊单#%d 当前医助已变更(非自动分配的医助),跳过回退',
$logId,
$diagnosisId
);
}
$prevAssistantId = self::resolvePreviousAssistantIdFromAutoAssign(
$diagnosisId,
$assignedAssistantId,
(int) ($log['create_time'] ?? 0)
);
$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;
}
Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->update([
'assistant_id' => $prevAssistantId,
'assign_read_at' => $prevAssistantId > 0 ? null : 0,
]);
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => $currentAssistantId,
'to_assistant_id' => $prevAssistantId,
'operator_admin_id' => $adminId,
'operator_name' => $adminName !== '' ? $adminName : '回退自动分配',
'operator_account' => $adminAccount,
'ip' => $ip,
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'is_inherit' => 0,
'create_time' => $now,
]);
Db::name('tcm_diagnosis_auto_assign_log')
->where('id', $logId)
->update([
'rollback_time' => $now,
'rollback_admin_id' => $adminId,
'rollback_admin_name' => $adminName,
]);
Db::commit();
return true;
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
/**
* 从「系统自动分配」指派日志取 from_assistant_id 作为回退目标。
*/
private static function resolvePreviousAssistantIdFromAutoAssign(
int $diagnosisId,
int $assignedAssistantId,
int $autoLogCreateTime
): int {
$query = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', $assignedAssistantId)
->where('operator_name', '系统自动分配');
if ($autoLogCreateTime > 0) {
$query->where('create_time', '>=', $autoLogCreateTime - 30)
->where('create_time', '<=', $autoLogCreateTime + 30);
}
$row = $query->order('id', 'desc')->field(['from_assistant_id'])->find();
if ($row !== null && $row !== []) {
return (int) ($row['from_assistant_id'] ?? 0);
}
// 兜底:不限时间窗再查最近一条系统自动分配
$fallback = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', $assignedAssistantId)
->where('operator_name', '系统自动分配')
->order('id', 'desc')
->field(['from_assistant_id'])
->find();
return (int) ($fallback['from_assistant_id'] ?? 0);
}
}
@@ -39,6 +39,7 @@ use think\facade\Log;
*
* 日志:无论分配与否,每条待指派诊单都会写入 tcm_diagnosis_auto_assign_log,记录原因(为什么分配 / 为什么不分配)。
* 成功分配同时写 tcm_diagnosis_assign_logis_inherit=0,计入次月接诊率分母),与手动指派同口径。
* 写入时 from_assistant_id 尽量带上「一中心」原医助(指派日志中最近的一中心身份,否则回退业务单创建人若属一中心),避免原医助恒为空。
*/
class AutoAssignPendingDiagnosis extends Command
{
@@ -304,6 +305,7 @@ class AutoAssignPendingDiagnosis extends Command
->where('run_date', $runDate)
->where('action', 1)
->where('assistant_id', '>', 0)
->where('rollback_time', 0)
->group('assistant_id')
->column('COUNT(*)', 'assistant_id');
@@ -499,9 +501,12 @@ class AutoAssignPendingDiagnosis extends Command
$relatedPoCreatorId = 0;
}
// 自动分给二中心时,原医助带上一中心身份(列表「原医助」不再恒为空)
$fromAssistantId = $this->resolveYiCenterPreviousAssistantId($diagnosisId, $relatedPoCreatorId);
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => 0,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => 0,
'operator_name' => '系统自动分配',
@@ -524,6 +529,88 @@ class AutoAssignPendingDiagnosis extends Command
}
}
/**
* 解析诊单对应的「一中心」原医助 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
+1 -1
View File
@@ -18,7 +18,7 @@ use think\console\Output;
* 配置 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
* 已对「待释放医助非二中心」执行发货/签收自动释放的诊单会写入 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
@@ -34,7 +34,7 @@ use think\console\Output;
* 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
* 已对「待释放医助非二中心」(发货/签收自动释放规则见 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
@@ -588,7 +588,7 @@ class ExpressTrackingService
/**
* 处方业务订单履约「已发货(5) / 已签收(6)」时:清空关联诊单(患者)医助,并写入 tcm_diagnosis_assign_log(含关联业务订单 creator_id / create_time 快照,便于核对医助创建订单时间)。
* 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则。
* 不自动清空的情形:(1按业务订单 creator_id(代建场景)或回退身份判断是否在「二中心」部门子树;(2)诊单已有 shipped_non_er_assistant_cleared_at 且 assistant_id 已为 0(视为已处理);(3)指派日志存在后台人工指派:`operator_admin_id>0` 且 `to_assistant_id>0`(系统任务为 operator_admin_id=0;或 related_po_creator_id=from_assistant_id 且 to>0 的旧数据兼容。避免「系统·已发货履约核对」覆盖人工指派链。
* 不自动清空的情形:(1待释放医助(诊单 assistant_id,或代建回退身份)归属「二中心」部门子树;(2)诊单当前已指派、或指派日志曾 to 给「二中心」医助(含系统自动分配);(3)诊单已有 shipped_non_er_assistant_cleared_at 且 assistant_id 已为 0(视为已处理);(4)指派日志存在后台人工指派:`operator_admin_id>0` 且 `to_assistant_id>0`;或 related_po_creator_id=from_assistant_id 且 to>0 的旧数据兼容。避免「系统·已发货履约核对」覆盖人工指派链。
*
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
* @return array{
@@ -663,19 +663,18 @@ class ExpressTrackingService
return null;
}
/** 是否因「二中心」跳过释放:优先看业务订单创建人 creator_id(代建场景);创建人即开方医生时用 fromAssistantId */
$erZhongxinCheckAdminId = ($poCreatorId > 0 && ($rxId <= 0 || $poCreatorId !== $rxCreator))
? $poCreatorId
: $fromAssistantId;
if ($erZhongxinCheckAdminId > 0 && self::currentAssistantBelongsToErzhongxinDeptTree($erZhongxinCheckAdminId)) {
Log::info('Skipped auto assistant release (shipped/signed): order creator (or fallback from-id) under 二中心 dept tree', [
// 二中心不释放:(a)待释放医助属二中心;(b)诊单当前医助属二中心;(c)指派日志曾指派给二中心医助
$skipErzhongxin = self::currentAssistantBelongsToErzhongxinDeptTree($fromAssistantId)
|| ($diagAssistantIdNow > 0 && self::currentAssistantBelongsToErzhongxinDeptTree($diagAssistantIdNow))
|| self::diagnosisHasBeenAssignedToErzhongxinAssistant($diagnosisId);
if ($skipErzhongxin) {
Log::info('Skipped auto assistant release (shipped/signed): 二中心 assistant assigned or targeted', [
'diagnosis_id' => $diagnosisId,
'prescription_order_id' => $orderId,
'source' => $source,
'er_zhongxin_check_admin_id' => $erZhongxinCheckAdminId,
'prescription_order_creator_id' => $poCreatorId,
'from_assistant_id_for_log' => $fromAssistantId,
'from_assistant_id' => $fromAssistantId,
'diagnosis_assistant_id' => $diagAssistantIdNow,
'prescription_order_creator_id' => $poCreatorId,
]);
return null;
@@ -798,6 +797,36 @@ class ExpressTrackingService
return false;
}
/**
* 诊单指派日志是否曾指派给「二中心」医助(to_assistant_id>0,含人工指派与系统自动分配)。
*/
private static function diagnosisHasBeenAssignedToErzhongxinAssistant(int $diagnosisId): bool
{
if ($diagnosisId <= 0) {
return false;
}
$toIds = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', '>', 0)
->column('to_assistant_id');
if ($toIds === [] || $toIds === null) {
return false;
}
$seen = [];
foreach ($toIds as $aid) {
$aid = (int) $aid;
if ($aid <= 0 || isset($seen[$aid])) {
continue;
}
$seen[$aid] = true;
if (self::currentAssistantBelongsToErzhongxinDeptTree($aid)) {
return true;
}
}
return false;
}
/**
* 若诊单曾被后台人工指派到具体医助,则不再执行发运/签收自动清空(避免「系统·已发货履约核对」覆盖人工链)。
* 判定:operator_admin_id>0 且 to_assistant_id>0(人工);系统写入的释放/同步为 operator_admin_id=0。
@@ -1031,7 +1060,7 @@ class ExpressTrackingService
/**
* 按处方订单履约核对:仅已发货(5) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder(已完成/已签收不再扫描)。
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看业务单创建人**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看待释放医助**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
*
* @return array{scanned: int, cleared: int, lines: list<string>}
*/