更新
This commit is contained in:
@@ -9,7 +9,8 @@ use app\adminapi\logic\stats\AssistantPerformanceLogic;
|
||||
/**
|
||||
* 医助个人业绩
|
||||
*
|
||||
* - GET stats.assistantPerformance/overview 个人业绩概览
|
||||
* - GET stats.assistantPerformance/overview 个人业绩概览
|
||||
* - GET stats.assistantPerformance/revisitRate 我的复诊接诊率(仅本人行,需在菜单登记:server/sql/1.9.20261012/10_add_assistant_revisit_rate_menu.sql)
|
||||
*/
|
||||
class AssistantPerformanceController extends BaseAdminController
|
||||
{
|
||||
@@ -18,4 +19,13 @@ class AssistantPerformanceController extends BaseAdminController
|
||||
$params = $this->request->get();
|
||||
return $this->data(AssistantPerformanceLogic::overview($params, $this->adminId, $this->adminInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 我的复诊接诊率:按月返回本人在复诊接诊率报表中的行(方案 §10 D1-h)
|
||||
*/
|
||||
public function revisitRate()
|
||||
{
|
||||
$month = (string) $this->request->get('month', '');
|
||||
return $this->data(AssistantPerformanceLogic::revisitRate($this->adminId, $month));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\tcm;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\tcm\RevisitDueLists;
|
||||
use app\adminapi\logic\tcm\RevisitDueLogic;
|
||||
use app\adminapi\validate\tcm\RevisitDueValidate;
|
||||
|
||||
/**
|
||||
* 复诊到期弹窗控制器(需求 4:到期未面诊弹窗 + 延迟处理;P2 主管团队视图 lists / logs)
|
||||
*
|
||||
* URL 自动映射 /adminapi/tcm.revisitDue/<action>,权限串 tcm.revisitDue/<action>,
|
||||
* 弹窗六个 action 在菜单登记于 server/sql/1.9.20261012/05_add_revisit_due_menu.sql,
|
||||
* 主管视图 lists(C 类页面 /stats/revisit_due)/ logs(A 类)登记于 08_add_revisit_due_team_menu.sql,
|
||||
* 否则 AuthMiddleware 对未登记接口放行。归属校验在 RevisitDueLogic 内完成,lists 的数据范围由 RevisitDueLists 处理。
|
||||
*
|
||||
* @package app\adminapi\controller\tcm
|
||||
*/
|
||||
class RevisitDueController extends BaseAdminController
|
||||
{
|
||||
/**
|
||||
* @notes 主管团队视图:到期跟进列表(分页 / 筛选 / 导出 export=1|2),数据范围按角色 data_scope
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new RevisitDueLists());
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 主管团队视图:某条提醒的事件流水(需在当前账号数据范围内)
|
||||
*/
|
||||
public function logs()
|
||||
{
|
||||
$params = (new RevisitDueValidate())->get()->goCheck('logs');
|
||||
$result = RevisitDueLogic::logs((int) $params['due_id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(RevisitDueLogic::getError() ?: '流水读取失败');
|
||||
}
|
||||
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 弹窗待办(轮询):本人到期未挂号 / 过号且已到提醒时刻的项 + 汇总计数
|
||||
*/
|
||||
public function pending()
|
||||
{
|
||||
return $this->data(RevisitDueLogic::pending($this->adminId, $this->adminInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 仅计数(不更新弹出时间)
|
||||
*/
|
||||
public function summary()
|
||||
{
|
||||
return $this->data(RevisitDueLogic::summary($this->adminId, $this->adminInfo));
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 延迟一次(幂等:affected=0 也返回当前行)
|
||||
*/
|
||||
public function snooze()
|
||||
{
|
||||
$params = (new RevisitDueValidate())->post()->goCheck('snooze');
|
||||
$result = RevisitDueLogic::snooze((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(RevisitDueLogic::getError() ?: '延迟失败');
|
||||
}
|
||||
|
||||
return $this->success($result['message'], $result['item'], 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 批量延迟(仅 escalation_level<=1 的项)
|
||||
*/
|
||||
public function snoozeAll()
|
||||
{
|
||||
$params = (new RevisitDueValidate())->post()->goCheck('snoozeAll');
|
||||
$result = RevisitDueLogic::snoozeAll((array) $params['ids'], $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success($result['message'], $result, 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 已联系:记录患者承诺复诊日
|
||||
*/
|
||||
public function contact()
|
||||
{
|
||||
$params = (new RevisitDueValidate())->post()->goCheck('contact');
|
||||
$result = RevisitDueLogic::contact(
|
||||
(int) $params['id'],
|
||||
(string) $params['promised_date'],
|
||||
(string) ($params['remark'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(RevisitDueLogic::getError() ?: '记录失败');
|
||||
}
|
||||
|
||||
return $this->success($result['message'], $result['item'], 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 人工关闭(原因 lost|other + 说明必填)
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
$params = (new RevisitDueValidate())->post()->goCheck('close');
|
||||
$result = RevisitDueLogic::close(
|
||||
(int) $params['id'],
|
||||
(string) $params['reason'],
|
||||
(string) ($params['remark'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(RevisitDueLogic::getError() ?: '关闭失败');
|
||||
}
|
||||
|
||||
return $this->success($result['message'], $result['item'], 1, 1);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
|
||||
'50_60' => '50%~60%',
|
||||
];
|
||||
|
||||
/** 分档口径版本(auto_assign_log.rate_version)文案 */
|
||||
private const RATE_VERSION_LABELS = [
|
||||
'v1' => '旧口径·分母仅新指派',
|
||||
'v2' => '新口径·每列独立分母',
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 设置搜索条件
|
||||
*/
|
||||
@@ -49,6 +55,12 @@ 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;
|
||||
// 两套口径:visit2_rate=新口径 v2(每列独立分母),visit2_rate_new_assign=旧口径 v1(分母仅新指派);rate_version=本批次分档所用口径
|
||||
$newAssignRate = $row['visit2_rate_new_assign'] ?? null;
|
||||
$row['visit2_rate_new_assign'] = $newAssignRate !== null ? (float) $newAssignRate : null;
|
||||
$rateVersion = trim((string) ($row['rate_version'] ?? ''));
|
||||
$row['rate_version'] = $rateVersion !== '' ? $rateVersion : 'v1';
|
||||
$row['rate_version_text'] = self::RATE_VERSION_LABELS[$row['rate_version']] ?? $row['rate_version'];
|
||||
|
||||
$rbTs = (int) ($row['rollback_time'] ?? 0);
|
||||
$row['rollback_time_text'] = $rbTs > 0 ? date('Y-m-d H:i:s', $rbTs) : '';
|
||||
|
||||
@@ -31,8 +31,10 @@ use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\dict\DictData;
|
||||
use app\common\model\tcm\DiagnosisRevisitDue;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\lists\Traits\HasDataScopeFilter;
|
||||
use app\common\service\tcm\RevisitDueService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
@@ -143,6 +145,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
|
||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||
$this->applyRevisitDueFilters($query, $pendingWideSearch);
|
||||
|
||||
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||
|
||||
@@ -460,6 +463,19 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
unset($item);
|
||||
|
||||
// 复诊到期(契约 C):按快照表批量读取,缺行/不一致时由服务内部自愈(仅小页前 50 行);读取异常由服务降级为 none 占位
|
||||
$revisitDueMap = [];
|
||||
if (!empty($diagIdsForUnserved)) {
|
||||
$revisitDueMap = RevisitDueService::buildListPayload($diagIdsForUnserved, [
|
||||
'self_heal' => true,
|
||||
'page_size' => (int) $this->limitLength,
|
||||
]);
|
||||
}
|
||||
foreach ($lists as &$item) {
|
||||
$item['revisit_due'] = $revisitDueMap[(int) $item['id']] ?? RevisitDueService::emptyPayload();
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return $lists;
|
||||
}
|
||||
|
||||
@@ -598,6 +614,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
|
||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||
$this->applyRevisitDueFilters($query, $pendingWideSearch);
|
||||
|
||||
// 仅已开方(待分配+关键词检索时不限制)
|
||||
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||
@@ -714,11 +731,120 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表默认排序;支持 sort_unserved_days=asc|desc 按未服务天数排序。
|
||||
* 复诊到期筛选(方案 §3.2 ②):以快照表 tcm_diagnosis_revisit_due 的 EXISTS 子查询实现。
|
||||
*
|
||||
* revisit_state:due(超期 ∪ 过号,排除已承诺未到期)| warning(到期前 warn_days 内)| booked(已约且日期未过)
|
||||
* | visited(已面诊)| contacted(已承诺复诊日未过)| no_days(有有效订单但缺开药天数)| pending(到期日在预警窗之外)
|
||||
* revisit_days_bucket:14|21|28|30 精确命中档位;other = 未命中档位且有开药天数
|
||||
* 待分配宽搜不套此过滤;lists() 与 count() 各调用一次保证分页总数一致。
|
||||
*
|
||||
* @param mixed $query
|
||||
*/
|
||||
private function applyRevisitDueFilters($query, bool $pendingWideSearch): void
|
||||
{
|
||||
if ($pendingWideSearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = strtolower(trim((string) ($this->params['revisit_state'] ?? '')));
|
||||
$bucket = strtolower(trim((string) ($this->params['revisit_days_bucket'] ?? '')));
|
||||
if ($state === '' && $bucket === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$rdTbl = (new DiagnosisRevisitDue())->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$conditions = ["rd.diagnosis_id = {$diagTbl}.id"];
|
||||
|
||||
if ($state !== '') {
|
||||
$warnDays = (int) (RevisitDueService::config()['warn_days'] ?? 3);
|
||||
if ($warnDays < 0) {
|
||||
$warnDays = 0;
|
||||
}
|
||||
$warnEdge = "DATE_ADD(CURDATE(), INTERVAL {$warnDays} DAY)";
|
||||
switch ($state) {
|
||||
case 'due':
|
||||
$conditions[] = 'rd.closed_state = 0';
|
||||
$conditions[] = '(rd.appointment_state = 4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE()))';
|
||||
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||
break;
|
||||
case 'warning':
|
||||
// 与读取侧 deriveState 对齐:过号(4)/已约已过期/已联系(承诺日未到) 的行优先级更高,不落入预警
|
||||
$conditions[] = 'rd.closed_state = 0';
|
||||
$conditions[] = 'rd.due_date IS NOT NULL';
|
||||
$conditions[] = 'rd.due_date > CURDATE()';
|
||||
$conditions[] = "rd.due_date <= {$warnEdge}";
|
||||
$conditions[] = 'rd.appointment_state <> 4';
|
||||
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
|
||||
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||
break;
|
||||
case 'booked':
|
||||
$conditions[] = 'rd.appointment_state = 1';
|
||||
$conditions[] = 'rd.appointment_date IS NOT NULL';
|
||||
$conditions[] = 'rd.appointment_date >= CURDATE()';
|
||||
break;
|
||||
case 'visited':
|
||||
$conditions[] = '(rd.closed_state = 3 OR rd.appointment_state = 3)';
|
||||
break;
|
||||
case 'contacted':
|
||||
$conditions[] = 'rd.promised_date IS NOT NULL';
|
||||
$conditions[] = 'rd.promised_date >= CURDATE()';
|
||||
break;
|
||||
case 'no_days':
|
||||
// 与 deriveState 的 no_days 分支对齐:进行中、有有效订单但算不出预计复诊日(失效行不计)
|
||||
$conditions[] = 'rd.closed_state = 0';
|
||||
$conditions[] = 'rd.valid_order_count > 0';
|
||||
$conditions[] = 'rd.due_date IS NULL';
|
||||
break;
|
||||
case 'pending':
|
||||
$conditions[] = 'rd.closed_state = 0';
|
||||
$conditions[] = 'rd.due_date IS NOT NULL';
|
||||
$conditions[] = "rd.due_date > {$warnEdge}";
|
||||
$conditions[] = 'rd.appointment_state <> 4';
|
||||
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
|
||||
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||
break;
|
||||
default:
|
||||
// 未知状态值:不做任何收窄(与其它未知筛选参数一致,避免误清空列表)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bucket !== '') {
|
||||
if ($bucket === 'other') {
|
||||
$conditions[] = 'rd.rule_days = 0';
|
||||
$conditions[] = 'rd.medication_days > 0';
|
||||
} elseif (ctype_digit($bucket) && (int) $bucket > 0) {
|
||||
$conditions[] = 'rd.rule_days = ' . (int) $bucket;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($conditions) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereExists("SELECT 1 FROM {$rdTbl} rd WHERE " . implode(' AND ', $conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表默认排序;支持 sort_revisit_due=asc|desc 按复诊到期日排序(优先),
|
||||
* 或 sort_unserved_days=asc|desc 按未服务天数排序;两者互斥,前者存在时忽略后者。
|
||||
*/
|
||||
private function resolveListOrderRaw(bool $pendingWideSearch): string
|
||||
{
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
|
||||
// 复诊到期日排序(方案 §3.2 ③):单行子查询取进行中快照的 due_date,无快照 / 无到期日的行置后
|
||||
$sortRevisitDue = strtolower(trim((string) ($this->params['sort_revisit_due'] ?? '')));
|
||||
if (in_array($sortRevisitDue, ['asc', 'desc'], true)) {
|
||||
$rdTbl = (new DiagnosisRevisitDue())->getTable();
|
||||
$dueExpr = "(SELECT rd.due_date FROM {$rdTbl} rd WHERE rd.diagnosis_id = {$diagTbl}.id AND rd.closed_state = 0 LIMIT 1)";
|
||||
$nullLast = "CASE WHEN {$dueExpr} IS NULL THEN 1 ELSE 0 END ASC";
|
||||
$dir = $sortRevisitDue === 'desc' ? 'DESC' : 'ASC';
|
||||
|
||||
return "{$nullLast}, {$dueExpr} {$dir}, {$diagTbl}.id DESC";
|
||||
}
|
||||
|
||||
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
|
||||
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
|
||||
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
|
||||
|
||||
@@ -25,6 +25,7 @@ use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use app\common\service\pharmacy\EjPharmacyClient;
|
||||
use app\common\service\tcm\RevisitVisitSlotService;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use think\db\Query;
|
||||
@@ -1464,7 +1465,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)
|
||||
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)。
|
||||
* 诊次口径单点在 RevisitVisitSlotService:有效订单序列(fetchOrderSeqRows,一次 SQL 覆盖全部诊单,消除逐诊单 N+1)
|
||||
* + 诊单偏移(fetchRevisitSlotStartOffsetMap / resolveRevisitSlotStartOffset 钳 0~20);global_visit_seq / counts_for_revisit_rate 规则不变。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $lists
|
||||
*/
|
||||
@@ -1490,26 +1493,23 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
return;
|
||||
}
|
||||
|
||||
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
|
||||
->whereNull('delete_time')
|
||||
->column('revisit_slot_start_offset', 'id');
|
||||
$offsetMap = [];
|
||||
foreach ($offsetRows as $id => $offset) {
|
||||
$offsetMap[(int) $id] = max(0, min(20, (int) $offset));
|
||||
}
|
||||
$offsetMap = RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIdList);
|
||||
|
||||
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
|
||||
/** @var array<int, int> $seqByOrderId order_id => 诊单内实单全局序号 */
|
||||
$seqByOrderId = [];
|
||||
foreach ($diagIdList as $did) {
|
||||
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
|
||||
$orderIds = $q
|
||||
->order(['create_time' => 'asc', 'id' => 'asc'])
|
||||
->column('id');
|
||||
foreach (array_chunk($diagIdList, 2000) as $chunk) {
|
||||
// 已按 (diagnosis_id, create_time, id) 升序,同一诊单连续
|
||||
$orderRows = RevisitVisitSlotService::fetchOrderSeqRows($chunk, ['o.id', 'o.diagnosis_id']);
|
||||
$curDid = 0;
|
||||
$seq = 0;
|
||||
foreach ($orderIds as $oid) {
|
||||
foreach ($orderRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did !== $curDid) {
|
||||
$curDid = $did;
|
||||
$seq = 0;
|
||||
}
|
||||
$seq++;
|
||||
$seqByOrderId[(int) $oid] = $seq;
|
||||
$seqByOrderId[(int) ($r['id'] ?? 0)] = $seq;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1517,7 +1517,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$oid = (int) ($row['id'] ?? 0);
|
||||
$did = (int) ($row['diagnosis_id'] ?? 0);
|
||||
$seq = (int) ($seqByOrderId[$oid] ?? 0);
|
||||
$offset = (int) ($offsetMap[$did] ?? 0);
|
||||
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
|
||||
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
|
||||
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\tcm;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\tcm\RevisitDueLogic;
|
||||
use app\common\lists\ListsExcelInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\lists\Traits\HasDataScopeFilter;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\model\tcm\DiagnosisRevisitDue;
|
||||
use app\common\service\tcm\RevisitDueCalculator;
|
||||
use app\common\service\tcm\RevisitDueService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 复诊到期跟进 - 主管团队视图列表(P2,方案 §3.3 tcm.revisitDue/lists)
|
||||
*
|
||||
* 数据来源:tcm_diagnosis_revisit_due 快照表(别名 rd),一诊单一行;时间相关状态由 RevisitDueCalculator::deriveState 按 today 推导。
|
||||
* 筛选:assistant_id / dept_id(经 admin_dept 子查询,含下级部门)/ state / days_bucket / min_snooze / keyword(患者姓名);默认排除失效行(closed_state<>5)。
|
||||
* state 的 SQL 条件与 DiagnosisLists::applyRevisitDueFilters 完全一致,另加 closed = closed_state=4(人工关闭,主管视图标记)。
|
||||
* 数据范围:HasDataScopeFilter::applyDataScopeByOwner('rd.assistant_id'),主管按角色 data_scope 看下属。
|
||||
* 排序:escalation_level desc(越点越上心优先),due_date asc(NULL 置后),id asc。
|
||||
* 导出:export=1/2,字段见 setExcelFields。
|
||||
*/
|
||||
class RevisitDueLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExcelInterface
|
||||
{
|
||||
use HasDataScopeFilter;
|
||||
|
||||
/** 支持的 state 筛选值 */
|
||||
public const STATES = ['due', 'warning', 'booked', 'visited', 'contacted', 'no_days', 'pending', 'closed'];
|
||||
|
||||
/**
|
||||
* @notes 设置搜索条件(精确匹配列走框架 createWhere,其余在 buildQuery 内处理)
|
||||
*/
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'=' => ['rd.assistant_id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取列表
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->buildQuery()
|
||||
->orderRaw('rd.escalation_level DESC, rd.due_date IS NULL ASC, rd.due_date ASC, rd.id ASC')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cfg = RevisitDueService::config();
|
||||
$today = date('Y-m-d');
|
||||
$assistantIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['assistant_id'], $rows)));
|
||||
$nameMap = $this->fetchAssistantNames($assistantIds);
|
||||
$deptMap = $this->fetchAssistantDeptNames($assistantIds);
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = $this->formatRow($row, $today, $cfg, $nameMap, $deptMap);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取数量
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return $this->buildQuery()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 导出文件名
|
||||
*/
|
||||
public function setFileName(): string
|
||||
{
|
||||
return '复诊到期跟进';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 导出字段
|
||||
*/
|
||||
public function setExcelFields(): array
|
||||
{
|
||||
return [
|
||||
'patient_name' => '患者',
|
||||
'assistant_name' => '医助',
|
||||
'dept_name' => '部门',
|
||||
'export_slot' => '第N诊',
|
||||
'medication_days' => '开药天数',
|
||||
'due_date' => '预计复诊',
|
||||
'export_overdue_days' => '超期天数',
|
||||
'state_text' => '状态',
|
||||
'snooze_count' => '延迟次数',
|
||||
'contact_count' => '已联系',
|
||||
'promised_date' => '承诺日',
|
||||
'missed_count' => '过号次数',
|
||||
'export_close_reason' => '关闭原因',
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 内部
|
||||
|
||||
/**
|
||||
* @return \think\db\Query
|
||||
*/
|
||||
private function buildQuery()
|
||||
{
|
||||
$query = Db::name(RevisitDueService::TABLE)->alias('rd')->where($this->searchWhere);
|
||||
|
||||
// 默认排除失效行(无医助 / 诊单删除或禁用 / 无有效订单)
|
||||
$query->where('rd.closed_state', '<>', RevisitDueCalculator::CLOSED_INVALID);
|
||||
|
||||
// 数据范围:主管按角色 data_scope 看下属医助
|
||||
$this->applyDataScopeByOwner($query, 'rd.assistant_id');
|
||||
|
||||
// 部门:医助 ∈ admin_dept 且部门在所选部门子树内
|
||||
$deptId = (int) ($this->params['dept_id'] ?? 0);
|
||||
if ($deptId > 0) {
|
||||
$deptIds = array_values(array_filter(array_map('intval', DeptLogic::getSelfAndDescendantIds($deptId)), static fn (int $v): bool => $v > 0));
|
||||
if ($deptIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
} else {
|
||||
$adTbl = (new AdminDept())->getTable();
|
||||
$query->whereExists('SELECT 1 FROM `' . $adTbl . '` ad WHERE ad.admin_id = rd.assistant_id AND ad.dept_id IN (' . implode(',', $deptIds) . ')');
|
||||
}
|
||||
}
|
||||
|
||||
// 患者姓名
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->whereLike('rd.patient_name', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
// 延迟次数下限
|
||||
$minSnooze = (int) ($this->params['min_snooze'] ?? 0);
|
||||
if ($minSnooze > 0) {
|
||||
$query->where('rd.snooze_count', '>=', $minSnooze);
|
||||
}
|
||||
|
||||
$this->applyStateFilter($query, strtolower(trim((string) ($this->params['state'] ?? ''))));
|
||||
$this->applyBucketFilter($query, strtolower(trim((string) ($this->params['days_bucket'] ?? ''))));
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* state 筛选:SQL 条件与 DiagnosisLists::applyRevisitDueFilters 完全一致(表别名 rd),另加 closed。
|
||||
*
|
||||
* @param mixed $query
|
||||
*/
|
||||
private function applyStateFilter($query, string $state): void
|
||||
{
|
||||
if ($state === '' || !in_array($state, self::STATES, true)) {
|
||||
return;
|
||||
}
|
||||
$warnDays = (int) (RevisitDueService::config()['warn_days'] ?? 3);
|
||||
if ($warnDays < 0) {
|
||||
$warnDays = 0;
|
||||
}
|
||||
$warnEdge = "DATE_ADD(CURDATE(), INTERVAL {$warnDays} DAY)";
|
||||
$conditions = [];
|
||||
switch ($state) {
|
||||
case 'due':
|
||||
$conditions[] = 'rd.closed_state = 0';
|
||||
$conditions[] = '(rd.appointment_state = 4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE()))';
|
||||
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||
break;
|
||||
case 'warning':
|
||||
$conditions[] = 'rd.closed_state = 0';
|
||||
$conditions[] = 'rd.due_date IS NOT NULL';
|
||||
$conditions[] = 'rd.due_date > CURDATE()';
|
||||
$conditions[] = "rd.due_date <= {$warnEdge}";
|
||||
$conditions[] = 'rd.appointment_state <> 4';
|
||||
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
|
||||
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||
break;
|
||||
case 'booked':
|
||||
$conditions[] = 'rd.appointment_state = 1';
|
||||
$conditions[] = 'rd.appointment_date IS NOT NULL';
|
||||
$conditions[] = 'rd.appointment_date >= CURDATE()';
|
||||
break;
|
||||
case 'visited':
|
||||
$conditions[] = '(rd.closed_state = 3 OR rd.appointment_state = 3)';
|
||||
break;
|
||||
case 'contacted':
|
||||
$conditions[] = 'rd.promised_date IS NOT NULL';
|
||||
$conditions[] = 'rd.promised_date >= CURDATE()';
|
||||
break;
|
||||
case 'no_days':
|
||||
$conditions[] = 'rd.closed_state = 0';
|
||||
$conditions[] = 'rd.valid_order_count > 0';
|
||||
$conditions[] = 'rd.due_date IS NULL';
|
||||
break;
|
||||
case 'pending':
|
||||
$conditions[] = 'rd.closed_state = 0';
|
||||
$conditions[] = 'rd.due_date IS NOT NULL';
|
||||
$conditions[] = "rd.due_date > {$warnEdge}";
|
||||
$conditions[] = 'rd.appointment_state <> 4';
|
||||
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
|
||||
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
|
||||
break;
|
||||
case 'closed':
|
||||
$conditions[] = 'rd.closed_state = ' . RevisitDueCalculator::CLOSED_MANUAL;
|
||||
break;
|
||||
}
|
||||
foreach ($conditions as $cond) {
|
||||
$query->whereRaw($cond);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* days_bucket:14|21|28|30 精确命中档位;other = 未命中档位且有开药天数。
|
||||
*
|
||||
* @param mixed $query
|
||||
*/
|
||||
private function applyBucketFilter($query, string $bucket): void
|
||||
{
|
||||
if ($bucket === '') {
|
||||
return;
|
||||
}
|
||||
if ($bucket === 'other') {
|
||||
$query->where('rd.rule_days', 0)->where('rd.medication_days', '>', 0);
|
||||
} elseif (ctype_digit($bucket) && (int) $bucket > 0) {
|
||||
$query->where('rd.rule_days', (int) $bucket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 行格式化:事实列 + deriveState 推导 + 医助/部门名 + 导出列。
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
* @param array<string, mixed> $cfg
|
||||
* @param array<int, string> $nameMap
|
||||
* @param array<int, string> $deptMap
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatRow(array $row, string $today, array $cfg, array $nameMap, array $deptMap): array
|
||||
{
|
||||
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$anchorTs = (int) ($row['anchor_ts'] ?? 0);
|
||||
$closed = (int) ($row['closed_state'] ?? 0);
|
||||
$slot = (int) ($row['current_slot'] ?? 0);
|
||||
$reason = (string) ($row['handle_reason'] ?? '');
|
||||
$remark = (string) ($row['handle_remark'] ?? '');
|
||||
$reasonText = RevisitDueLogic::CLOSE_REASON_TEXT[$reason] ?? $reason;
|
||||
$name = trim((string) ($row['patient_name'] ?? ''));
|
||||
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'diagnosis_id' => (int) ($row['diagnosis_id'] ?? 0),
|
||||
'patient_name' => $name !== '' ? $name : '患者',
|
||||
'assistant_id' => $aid,
|
||||
'assistant_name' => (string) ($nameMap[$aid] ?? ''),
|
||||
'dept_name' => (string) ($deptMap[$aid] ?? ''),
|
||||
'current_slot' => $slot,
|
||||
'next_slot' => (int) ($row['next_slot'] ?? 0),
|
||||
'medication_days' => (int) ($row['medication_days'] ?? 0),
|
||||
'rule_days' => (int) ($row['rule_days'] ?? 0),
|
||||
'anchor_date' => $anchorTs > 0 ? date('Y-m-d', $anchorTs) : '',
|
||||
'due_date' => self::dateStr($row['due_date'] ?? null),
|
||||
'overdue_days' => $st['overdue_days'],
|
||||
'days_left' => $st['days_left'],
|
||||
'state' => $st['state'],
|
||||
'state_text' => $st['state_text'],
|
||||
'severe' => (int) $st['severe'],
|
||||
'appointment_state' => (int) ($row['appointment_state'] ?? 0),
|
||||
'appointment_date' => self::dateStr($row['appointment_date'] ?? null),
|
||||
'snooze_count' => (int) ($row['snooze_count'] ?? 0),
|
||||
'escalation_level' => (int) ($row['escalation_level'] ?? 0),
|
||||
'contact_count' => (int) ($row['contact_count'] ?? 0),
|
||||
'promised_date' => self::dateStr($row['promised_date'] ?? null),
|
||||
'missed_count' => (int) ($row['missed_count'] ?? 0),
|
||||
'closed_state' => $closed,
|
||||
'closed_state_text' => DiagnosisRevisitDue::CLOSED_STATE_TEXT_MAP[$closed] ?? '未知',
|
||||
'handle_reason' => $reason,
|
||||
'handle_remark' => $remark,
|
||||
'first_shown_at_text' => self::tsText($row['first_shown_at'] ?? 0),
|
||||
'last_snoozed_at_text' => self::tsText($row['last_snoozed_at'] ?? 0),
|
||||
'computed_at_text' => self::tsText($row['computed_at'] ?? 0),
|
||||
// 导出列
|
||||
'export_slot' => $slot > 0 ? '第' . $slot . '诊' : '',
|
||||
'export_overdue_days' => $st['overdue_days'] === null ? '' : (string) $st['overdue_days'],
|
||||
'export_close_reason' => $closed === RevisitDueCalculator::CLOSED_MANUAL
|
||||
? trim($reasonText . ($remark !== '' ? ':' . $remark : ''))
|
||||
: '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function fetchAssistantNames(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_filter($ids, static fn (int $v): bool => $v > 0));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
$map = [];
|
||||
foreach (Admin::whereIn('id', $ids)->column('name', 'id') as $id => $n) {
|
||||
$map[(int) $id] = (string) $n;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助部门名:admin_dept 取最小 dept_id,再查 dept.name。
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function fetchAssistantDeptNames(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_filter($ids, static fn (int $v): bool => $v > 0));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
$minDept = [];
|
||||
$adRows = AdminDept::whereIn('admin_id', $ids)->field(['admin_id', 'dept_id'])->select()->toArray();
|
||||
foreach ($adRows as $ad) {
|
||||
$aid = (int) ($ad['admin_id'] ?? 0);
|
||||
$did = (int) ($ad['dept_id'] ?? 0);
|
||||
if ($aid <= 0 || $did <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($minDept[$aid]) || $did < $minDept[$aid]) {
|
||||
$minDept[$aid] = $did;
|
||||
}
|
||||
}
|
||||
if ($minDept === []) {
|
||||
return [];
|
||||
}
|
||||
$deptNames = [];
|
||||
foreach (Dept::whereIn('id', array_values(array_unique($minDept)))->column('name', 'id') as $id => $n) {
|
||||
$deptNames[(int) $id] = (string) $n;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($minDept as $aid => $did) {
|
||||
$out[$aid] = (string) ($deptNames[$did] ?? '');
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 时间戳 → 'Y-m-d H:i' 或 '' */
|
||||
private static function tsText(mixed $ts): string
|
||||
{
|
||||
$ts = (int) $ts;
|
||||
|
||||
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
|
||||
}
|
||||
|
||||
/** 日期列 → 'Y-m-d' 或 ''(null / 空 / 0000-00-00 均视为空) */
|
||||
private static function dateStr(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$s = substr(trim((string) $value), 0, 10);
|
||||
|
||||
return ($s === '' || $s === '0000-00-00') ? '' : $s;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||
use app\common\service\tcm\RevisitDueHook;
|
||||
use app\common\model\dict\DictData;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
@@ -404,6 +405,9 @@ class AppointmentLogic extends BaseLogic
|
||||
$summary
|
||||
);
|
||||
|
||||
// 事务提交后刷新复诊到期快照(新挂号 → 已约自动关闭;钩子内部吞异常)
|
||||
RevisitDueHook::onAppointmentChanged((int) $params['patient_id'], $newId, 'create');
|
||||
|
||||
return ['id' => $newId];
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
@@ -467,6 +471,9 @@ class AppointmentLogic extends BaseLogic
|
||||
$summary
|
||||
);
|
||||
|
||||
// 刷新复诊到期快照(取消挂号 → 已约行重开;钩子内部吞异常)
|
||||
RevisitDueHook::onAppointmentChanged($diagId, $aptId, 'cancel');
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
@@ -618,6 +625,9 @@ class AppointmentLogic extends BaseLogic
|
||||
Log::warning('完成挂号后系统代开处方失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 刷新复诊到期快照(面诊完成 → 已面诊关闭;钩子内部吞异常)
|
||||
RevisitDueHook::onAppointmentChanged((int) $appointment->patient_id, (int) $appointment->id, 'complete');
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
@@ -1161,6 +1171,9 @@ class AppointmentLogic extends BaseLogic
|
||||
);
|
||||
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
|
||||
|
||||
// 事务提交后刷新复诊到期快照(日期/状态变更影响已约/过号/已面诊判定;钩子内部吞异常)
|
||||
RevisitDueHook::onAppointmentChanged($patientId, $id, 'admin_edit');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
|
||||
@@ -10,6 +10,22 @@ class AssistantPerformanceLogic
|
||||
/** 履约完成 */
|
||||
private const FULFILLMENT_COMPLETED = 3;
|
||||
|
||||
/**
|
||||
* 「我的复诊接诊率」卡片(方案 §10 D1-h):仅返回当前登录医助本人的复诊接诊率行。
|
||||
* 口径与复诊接诊率页一致(inherit_mode 取配置 project.revisit_rate.default_inherit_mode,默认 slot:
|
||||
* 继承患者按继承时诊次从第 k+1 诊起计入分母);非二中心医助或当月无被指派患者时 row=null。
|
||||
*
|
||||
* @param string $month 统计月份 YYYY-MM,空 / 非法时回退当前月
|
||||
*
|
||||
* @return array{month:string,inherit_mode:string,slots:list<int>,in_scope:bool,row:array<string,mixed>|null}
|
||||
*/
|
||||
public static function revisitRate(int $adminId, string $month): array
|
||||
{
|
||||
$mode = (string) config('project.revisit_rate.default_inherit_mode', 'slot');
|
||||
|
||||
return RevisitRateLogic::assistantOverview($adminId, $month, $mode);
|
||||
}
|
||||
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
// 时间范围解析
|
||||
|
||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\service\tcm\RevisitDueHook;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 待分配诊单自动指派日志:回退已自动分配的医助
|
||||
*
|
||||
* 回退写入的指派日志保持原逻辑 is_inherit=0(复诊接诊率原口径不变,用户要求);
|
||||
* 复诊接诊率「新口径」下,回退行由 RevisitRateLogic 按 (diagnosis_id, rollback_time, rollback_admin_id) 匹配后按继承处理,不依赖本字段。
|
||||
*/
|
||||
class AutoAssignLogLogic extends BaseLogic
|
||||
{
|
||||
@@ -183,6 +187,9 @@ class AutoAssignLogLogic extends BaseLogic
|
||||
|
||||
Db::commit();
|
||||
|
||||
// 事务提交后刷新复诊到期快照(回退 = 换回原医助或释放 to=0;钩子内部吞异常)
|
||||
RevisitDueHook::onAssistantChanged($diagnosisId, $prevAssistantId, 'auto_assign_rollback');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\common\service\tcm\RevisitVisitSlotService;
|
||||
|
||||
/**
|
||||
* 复诊接诊率 v2 纯计算器(不连库,承接 RevisitRateLogic::buildStatsCore 的内存计算;单测可直接驱动)。
|
||||
*
|
||||
* 口径(方案 §1.1,定义 A 累计型、每列独立分母):
|
||||
* - NI(A):新指派集合 = 当月 is_inherit=0、to=A 的指派日志对应诊单(slot 模式下忽略 from==to 行)\ R;
|
||||
* - IH(A):继承集合 = 当月 is_inherit=1(或 slot 模式下命中历史回退行)、to=A、from≠to 的诊单,且 D ∉ NI(A),\ R;
|
||||
* 同一 (A, D) 当月多条继承取最早一条;
|
||||
* - R:名下存在履约拒收(9)/退款(10) 订单的诊单(由调用方查库后传入);
|
||||
* - 继承时诊次 k_D = slotBefore(该诊单有效订单 create_time 列表, offset, t_l)(严格小于,与持有人规则 t <= ct 对偶);
|
||||
* 起算诊次 entry_D = max(inherit_min_next_slot, k_D + 1);entry_D > MAX_VISIT_SLOT 的继承行只进合计分母、不进任何诊次列;
|
||||
* - 分子 Num_s(A):当月下单、slot = s、2 <= s <= 50、下单时点持有人为 A 的订单(与原实现完全一致,不受模式影响);
|
||||
* - 无论模式都另行输出 NI_exclude(A)(:380 现状口径:仅 is_inherit=0,不忽略 from==to、不豁免回退行),
|
||||
* 供 visit2_rate_new_assign 使用。
|
||||
*
|
||||
* inherit_mode:
|
||||
* - exclude:完全复现旧口径,IH = ∅,NI = NI_exclude;
|
||||
* - slot :继承按继承时诊次计入每列独立分母。
|
||||
*/
|
||||
final class RevisitRateCalculator
|
||||
{
|
||||
public const MODE_EXCLUDE = 'exclude';
|
||||
public const MODE_SLOT = 'slot';
|
||||
|
||||
/** 复诊序号统计上限(与 RevisitRateLogic::MAX_VISIT_SLOT 一致) */
|
||||
public const MAX_VISIT_SLOT = 50;
|
||||
|
||||
/** 继承起算诊次下限默认值(config project.revisit_rate.inherit_min_next_slot) */
|
||||
public const DEFAULT_MIN_NEXT_SLOT = 2;
|
||||
|
||||
/** 旧口径 :380 的字段名(供自动分配过渡期分档) */
|
||||
public const FIELD_RATE_NEW_ASSIGN = 'visit2_rate_new_assign';
|
||||
|
||||
/** 口径白名单归一化;非法值回退 $default */
|
||||
public static function normalizeMode(mixed $raw, string $default = self::MODE_SLOT): string
|
||||
{
|
||||
$mode = strtolower(trim((string) $raw));
|
||||
if ($mode === self::MODE_EXCLUDE || $mode === self::MODE_SLOT) {
|
||||
return $mode;
|
||||
}
|
||||
$default = strtolower(trim($default));
|
||||
|
||||
return $default === self::MODE_EXCLUDE ? self::MODE_EXCLUDE : self::MODE_SLOT;
|
||||
}
|
||||
|
||||
/** 历史回退行匹配键:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) ↔ assign_log(diagnosis_id, create_time, operator_admin_id) */
|
||||
public static function rollbackKey(int $diagnosisId, int $time, int $operatorAdminId): string
|
||||
{
|
||||
return $diagnosisId . '|' . $time . '|' . $operatorAdminId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当月被指派(to>0、t∈M,含继承 / from==to 行)的诊单 id 去重列表,供查询拒收/退款剔除集合。
|
||||
*
|
||||
* @param list<array<string, mixed>> $logRows
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function monthAssignedDiagIds(array $logRows, int $startTs, int $endTs): array
|
||||
{
|
||||
$set = [];
|
||||
foreach ($logRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
$aid = (int) ($r['to_assistant_id'] ?? 0);
|
||||
$t = (int) ($r['create_time'] ?? 0);
|
||||
if ($did > 0 && $aid > 0 && $t >= $startTs && $t <= $endTs) {
|
||||
$set[$did] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($set);
|
||||
}
|
||||
|
||||
/**
|
||||
* 曾被指派(to>0,任意时间,含继承)的诊单 id 列表:分子候选、订单序列拉取范围。
|
||||
*
|
||||
* @param list<array<string, mixed>> $logRows
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function candidateDiagIds(array $logRows): array
|
||||
{
|
||||
$set = [];
|
||||
foreach ($logRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did > 0 && (int) ($r['to_assistant_id'] ?? 0) > 0) {
|
||||
$set[$did] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($set);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心计算。
|
||||
*
|
||||
* @param list<array<string, mixed>> $logRows 指派日志(≤ 月末、诊单未删除),须按 (diagnosis_id, create_time, id) 升序;
|
||||
* 键:id, diagnosis_id, from_assistant_id, to_assistant_id, operator_admin_id, create_time, is_inherit
|
||||
* @param iterable<array<string, mixed>> $orderRows 有效订单行,须按 (diagnosis_id, create_time, id) 升序且同一诊单连续(可分块生成器);
|
||||
* 键:id, diagnosis_id, create_time(其余字段原样进入 slotOrdersByAssistant)
|
||||
* @param array<int, int> $offsetMap diagnosis_id => revisit_slot_start_offset(原始值)
|
||||
* @param array<int, true> $refundRejectSet 名下有拒收/退款订单的诊单集合 R
|
||||
* @param array<string, true> $rollbackKeySet 历史回退行键集合(rollbackKey()),仅 slot 模式使用
|
||||
* @param string $inheritMode exclude | slot
|
||||
* @param int $inheritMinNextSlot 继承起算诊次下限(默认 2)
|
||||
*
|
||||
* @return array{
|
||||
* inheritMode:string,
|
||||
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int}>,
|
||||
* diagsByAssistant:array<int,array<int,true>>,
|
||||
* excludeDiagsByAssistant:array<int,array<int,true>>,
|
||||
* inheritEntryByAssistant:array<int,array<int,int>>,
|
||||
* inheritPairs:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int,inherit_visit_slot:int,entry_slot:int,assign_count:int,is_rollback:int,inherit_visit_slot_snapshot:int}>,
|
||||
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
|
||||
* maxEntry:int
|
||||
* }
|
||||
*/
|
||||
public static function compute(
|
||||
array $logRows,
|
||||
iterable $orderRows,
|
||||
array $offsetMap,
|
||||
array $refundRejectSet,
|
||||
array $rollbackKeySet,
|
||||
int $startTs,
|
||||
int $endTs,
|
||||
string $inheritMode,
|
||||
int $inheritMinNextSlot = self::DEFAULT_MIN_NEXT_SLOT
|
||||
): array {
|
||||
$inheritMode = self::normalizeMode($inheritMode);
|
||||
$slotMode = $inheritMode === self::MODE_SLOT;
|
||||
if ($inheritMinNextSlot < 2) {
|
||||
$inheritMinNextSlot = 2;
|
||||
}
|
||||
|
||||
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序,含释放/继承/from==to 行) */
|
||||
$timeline = [];
|
||||
$pairsRaw = [];
|
||||
/** @var array<int, array<int, true>> $diagsByAssistant 当前模式下的 NI */
|
||||
$diagsByAssistant = [];
|
||||
/** @var array<int, array<int, true>> $excludeDiagsByAssistant :380 现状口径 NI_exclude */
|
||||
$excludeDiagsByAssistant = [];
|
||||
/** @var array<int, array<int, array<string, mixed>>> $inheritFirst 医助 => 诊单 => 最早一条继承日志(附 assign_count) */
|
||||
$inheritFirst = [];
|
||||
|
||||
foreach ($logRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
$aid = (int) ($r['to_assistant_id'] ?? 0);
|
||||
$from = (int) ($r['from_assistant_id'] ?? 0);
|
||||
$t = (int) ($r['create_time'] ?? 0);
|
||||
$isInherit = (int) ($r['is_inherit'] ?? 0) === 1;
|
||||
$logId = (int) ($r['id'] ?? 0);
|
||||
if ($did <= 0) {
|
||||
continue;
|
||||
}
|
||||
$timeline[$did][] = ['t' => $t, 'to' => $aid];
|
||||
if ($aid <= 0 || $t < $startTs || $t > $endTs) {
|
||||
continue;
|
||||
}
|
||||
// 旧口径集合:仅看 is_inherit=0(不忽略 from==to、不豁免回退行)
|
||||
if (!$isInherit) {
|
||||
$excludeDiagsByAssistant[$aid][$did] = true;
|
||||
}
|
||||
if (!$slotMode) {
|
||||
if (!$isInherit) {
|
||||
$pairsRaw[] = [
|
||||
'diagnosis_id' => $did,
|
||||
'to_assistant_id' => $aid,
|
||||
'from_assistant_id' => $from,
|
||||
'create_time' => $t,
|
||||
'log_id' => $logId,
|
||||
];
|
||||
$diagsByAssistant[$aid][$did] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// slot 模式:指派给自己(from == to)不计任何集合
|
||||
if ($from === $aid) {
|
||||
continue;
|
||||
}
|
||||
$isRollback = $rollbackKeySet !== []
|
||||
&& isset($rollbackKeySet[self::rollbackKey($did, $t, (int) ($r['operator_admin_id'] ?? 0))]);
|
||||
if ($isInherit || $isRollback) {
|
||||
if (!isset($inheritFirst[$aid][$did])) {
|
||||
$inheritFirst[$aid][$did] = [
|
||||
'diagnosis_id' => $did,
|
||||
'to_assistant_id' => $aid,
|
||||
'from_assistant_id' => $from,
|
||||
'create_time' => $t,
|
||||
'log_id' => $logId,
|
||||
'is_rollback' => $isRollback ? 1 : 0,
|
||||
'inherit_visit_slot_snapshot' => (int) ($r['inherit_visit_slot'] ?? 0),
|
||||
'assign_count' => 1,
|
||||
];
|
||||
} else {
|
||||
$inheritFirst[$aid][$did]['assign_count']++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$pairsRaw[] = [
|
||||
'diagnosis_id' => $did,
|
||||
'to_assistant_id' => $aid,
|
||||
'from_assistant_id' => $from,
|
||||
'create_time' => $t,
|
||||
'log_id' => $logId,
|
||||
];
|
||||
$diagsByAssistant[$aid][$did] = true;
|
||||
}
|
||||
|
||||
// D 同月既被非继承又被继承指派给同一 A 时只算 NI
|
||||
foreach ($inheritFirst as $aid => $diagMap) {
|
||||
foreach ($diagMap as $did => $_) {
|
||||
if (isset($diagsByAssistant[$aid][$did])) {
|
||||
unset($inheritFirst[$aid][$did]);
|
||||
}
|
||||
}
|
||||
if ($inheritFirst[$aid] === []) {
|
||||
unset($inheritFirst[$aid]);
|
||||
}
|
||||
}
|
||||
|
||||
// 剔除名下存在拒收(9)/退款(10) 业务订单的诊单(NI / NI_exclude / IH 同时生效)
|
||||
if ($refundRejectSet !== []) {
|
||||
$strip = static function (array &$map) use ($refundRejectSet): void {
|
||||
foreach ($map as $aid => $diagMap) {
|
||||
foreach ($diagMap as $did => $_) {
|
||||
if (isset($refundRejectSet[$did])) {
|
||||
unset($map[$aid][$did]);
|
||||
}
|
||||
}
|
||||
if ($map[$aid] === []) {
|
||||
unset($map[$aid]);
|
||||
}
|
||||
}
|
||||
};
|
||||
$strip($diagsByAssistant);
|
||||
$strip($excludeDiagsByAssistant);
|
||||
$strip($inheritFirst);
|
||||
$pairsRaw = array_values(array_filter(
|
||||
$pairsRaw,
|
||||
static fn (array $p): bool => !isset($refundRejectSet[$p['diagnosis_id']])
|
||||
));
|
||||
}
|
||||
|
||||
/** @var array<int, true> $inheritDiagSet 需要收集订单时间列表的诊单 */
|
||||
$inheritDiagSet = [];
|
||||
foreach ($inheritFirst as $diagMap) {
|
||||
foreach ($diagMap as $did => $_) {
|
||||
$inheritDiagSet[$did] = true;
|
||||
}
|
||||
}
|
||||
/** @var array<int, list<int>> $inheritOrderCts 继承诊单 => 有效订单 create_time 列表 */
|
||||
$inheritOrderCts = [];
|
||||
|
||||
// 分子:曾被指派诊单的订单,统计诊次 = 实单全局序号 + 诊单偏移;持有人 = 订单时间之前(含同刻)最近一次指派
|
||||
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
|
||||
$slotOrdersByAssistant = [];
|
||||
$curDid = 0;
|
||||
$seq = 0;
|
||||
$ptr = 0;
|
||||
$holder = 0;
|
||||
$offset = 0;
|
||||
foreach ($orderRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($did !== $curDid) {
|
||||
$curDid = $did;
|
||||
$seq = 0;
|
||||
$ptr = 0;
|
||||
$holder = 0;
|
||||
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||
}
|
||||
$seq++;
|
||||
$effectiveSlot = $seq + $offset;
|
||||
$ct = (int) ($r['create_time'] ?? 0);
|
||||
if (isset($inheritDiagSet[$did])) {
|
||||
$inheritOrderCts[$did][] = $ct;
|
||||
}
|
||||
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
||||
$tl = $timeline[$did] ?? [];
|
||||
$tlCount = \count($tl);
|
||||
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
|
||||
$holder = (int) $tl[$ptr]['to'];
|
||||
$ptr++;
|
||||
}
|
||||
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
|
||||
continue;
|
||||
}
|
||||
if ($ct < $startTs || $ct > $endTs) {
|
||||
continue;
|
||||
}
|
||||
if ($holder > 0) {
|
||||
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
// 继承时诊次 k 与起算诊次 entry
|
||||
/** @var array<int, array<int, int>> $inheritEntryByAssistant */
|
||||
$inheritEntryByAssistant = [];
|
||||
$inheritPairs = [];
|
||||
$maxEntry = 0;
|
||||
foreach ($inheritFirst as $aid => $diagMap) {
|
||||
foreach ($diagMap as $did => $row) {
|
||||
$k = RevisitVisitSlotService::slotBefore(
|
||||
$inheritOrderCts[$did] ?? [],
|
||||
RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap),
|
||||
(int) $row['create_time']
|
||||
);
|
||||
$entry = max($inheritMinNextSlot, $k + 1);
|
||||
$inheritEntryByAssistant[$aid][$did] = $entry;
|
||||
if ($entry <= self::MAX_VISIT_SLOT && $entry > $maxEntry) {
|
||||
$maxEntry = $entry;
|
||||
}
|
||||
$row['inherit_visit_slot'] = $k;
|
||||
$row['entry_slot'] = $entry;
|
||||
$inheritPairs[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'inheritMode' => $inheritMode,
|
||||
'pairsRaw' => $pairsRaw,
|
||||
'diagsByAssistant' => $diagsByAssistant,
|
||||
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
|
||||
'inheritEntryByAssistant' => $inheritEntryByAssistant,
|
||||
'inheritPairs' => $inheritPairs,
|
||||
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
||||
'maxEntry' => $maxEntry,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单集合按医助并集(部门行 / 合计行按诊单去重)。
|
||||
*
|
||||
* @param array<int, array<int, true>> $setsByAssistant
|
||||
* @param list<int> $assistantIds
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
public static function unionDiagSets(array $setsByAssistant, array $assistantIds): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($assistantIds as $aid) {
|
||||
foreach ($setsByAssistant[(int) $aid] ?? [] as $did => $_) {
|
||||
$out[(int) $did] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门行 / 合计行的 NI 与 IH 合并:NI_dept = ∪NI(A);IH_dept = (∪IH(A)) \ NI_dept,entry 取各继承者中的最小值。
|
||||
*
|
||||
* @param array<int, array<int, true>> $diagsByAssistant
|
||||
* @param array<int, array<int, int>> $inheritEntryByAssistant
|
||||
* @param list<int> $assistantIds
|
||||
*
|
||||
* @return array{0: array<int, true>, 1: array<int, int>} [NI 去重集合, 诊单 => entry]
|
||||
*/
|
||||
public static function mergeInheritEntries(array $diagsByAssistant, array $inheritEntryByAssistant, array $assistantIds): array
|
||||
{
|
||||
$niSet = self::unionDiagSets($diagsByAssistant, $assistantIds);
|
||||
$ih = [];
|
||||
foreach ($assistantIds as $aid) {
|
||||
foreach ($inheritEntryByAssistant[(int) $aid] ?? [] as $did => $entry) {
|
||||
$did = (int) $did;
|
||||
if (isset($niSet[$did])) {
|
||||
continue;
|
||||
}
|
||||
$entry = (int) $entry;
|
||||
$ih[$did] = isset($ih[$did]) ? min($ih[$did], $entry) : $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return [$niSet, $ih];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列范围:2 ~ min(MAX, max(minCeiling, 当月分子最大 slot, 最大起算诊次))。
|
||||
*
|
||||
* @param array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function slotRange(array $slotOrdersByAssistant, int $maxEntry, int $minCeiling = 4): array
|
||||
{
|
||||
$maxHitSlot = 0;
|
||||
foreach ($slotOrdersByAssistant as $slotMap) {
|
||||
foreach ($slotMap as $slot => $_) {
|
||||
if ((int) $slot > $maxHitSlot) {
|
||||
$maxHitSlot = (int) $slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
$upper = max($minCeiling, $maxHitSlot, $maxEntry);
|
||||
$upper = min(self::MAX_VISIT_SLOT, max(2, $upper));
|
||||
|
||||
return range(2, $upper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标包:
|
||||
* assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、
|
||||
* visit{N}_count / _den / _rate(Den_s = NI + |{entry <= s}|)、visit_all_count / _den / _rate、visit2_rate_new_assign。
|
||||
*
|
||||
* @param array<int, int> $inheritEntryByDiag 诊单 => 起算诊次
|
||||
* @param array<int, int> $slotCounts [slot => n]
|
||||
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
||||
*
|
||||
* @return array<string, int|float|null>
|
||||
*/
|
||||
public static function metricPack(
|
||||
int $newAssigned,
|
||||
array $inheritEntryByDiag,
|
||||
array $slotCounts,
|
||||
array $slots,
|
||||
int $newAssignedExclude
|
||||
): array {
|
||||
$inheritCount = \count($inheritEntryByDiag);
|
||||
/** @var array<int, int> $byEntry entry => 继承诊单数 */
|
||||
$byEntry = [];
|
||||
foreach ($inheritEntryByDiag as $entry) {
|
||||
$entry = (int) $entry;
|
||||
$byEntry[$entry] = ($byEntry[$entry] ?? 0) + 1;
|
||||
}
|
||||
ksort($byEntry);
|
||||
|
||||
$pack = [
|
||||
'assigned_count' => $newAssigned,
|
||||
'inherit_count' => $inheritCount,
|
||||
'assigned_total_count' => $newAssigned + $inheritCount,
|
||||
];
|
||||
foreach ($slots as $slot) {
|
||||
$slot = (int) $slot;
|
||||
$cnt = (int) ($slotCounts[$slot] ?? 0);
|
||||
$den = $newAssigned;
|
||||
foreach ($byEntry as $entry => $n) {
|
||||
if ($entry > $slot) {
|
||||
break;
|
||||
}
|
||||
$den += $n;
|
||||
}
|
||||
$pack['visit' . $slot . '_count'] = $cnt;
|
||||
$pack['visit' . $slot . '_den'] = $den;
|
||||
$pack['visit' . $slot . '_rate'] = $den > 0 ? round($cnt / $den * 100, 2) : null;
|
||||
}
|
||||
|
||||
$allCount = 0;
|
||||
foreach ($slotCounts as $slot => $cnt) {
|
||||
$slot = (int) $slot;
|
||||
if ($slot >= 2 && $slot <= self::MAX_VISIT_SLOT) {
|
||||
$allCount += (int) $cnt;
|
||||
}
|
||||
}
|
||||
$allDen = $newAssigned + $inheritCount;
|
||||
$pack['visit_all_count'] = $allCount;
|
||||
$pack['visit_all_den'] = $allDen;
|
||||
$pack['visit_all_rate'] = $allDen > 0 ? round($allCount / $allDen * 100, 2) : null;
|
||||
|
||||
$num2 = (int) ($slotCounts[2] ?? 0);
|
||||
$pack[self::FIELD_RATE_NEW_ASSIGN] = $newAssignedExclude > 0
|
||||
? round($num2 / $newAssignedExclude * 100, 2)
|
||||
: null;
|
||||
|
||||
return $pack;
|
||||
}
|
||||
}
|
||||
@@ -5,29 +5,32 @@ declare(strict_types=1);
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\service\tcm\RevisitVisitSlotService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 复诊接诊率统计(按月)
|
||||
* 复诊接诊率统计(按月)—— v2:继承按继承时诊次计入每列独立分母 + 复诊合计列(方案 §1.1,定义 A 累计型)。
|
||||
*
|
||||
* 口径说明:
|
||||
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
||||
* 同一诊单当月被多次指派给同一医助只计 1 次;
|
||||
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
|
||||
* 口径说明(内存计算见 RevisitRateCalculator,诊次口径见 RevisitVisitSlotService):
|
||||
* - 新指派数 assigned_count = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||
* **is_inherit=0**,诊单未删除)去重后的「医助 × 诊单」组合(NI);同一诊单当月多次指派给同一医助只计 1 次;
|
||||
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单(R,不限月份)。
|
||||
* - 继承数 inherit_count = 当月 **is_inherit=1**、to>0、from≠to 的「医助 × 诊单」(IH),同一 (医助, 诊单) 多条继承取最早;
|
||||
* 同月既新指派又继承给同一医助只算新指派;剔除 R。历史「回退自动分配」行(auto_assign_log.rollback_time 匹配)按继承处理。
|
||||
* 继承时诊次 k = 继承时刻之前(严格小于)的有效实单数 + 偏移;起算诊次 entry = max(2, k+1);entry>50 只进合计分母。
|
||||
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
||||
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
|
||||
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
|
||||
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
|
||||
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
|
||||
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
|
||||
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%;
|
||||
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
||||
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
||||
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
||||
* 诊次**跨月累计不重置**;诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;偏移 n 即在实单序号上叠加 n)。
|
||||
* - 当月 N 诊单数 visit{N}_count = **当月内下单**且诊次为 N 的订单数,归属下单时点的**持有医助**——
|
||||
* 按指派日志时间线取「订单时间之前(含同刻)最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;继承会转移持有人)。
|
||||
* - 当月 N 诊分母 visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数;接诊率 visit{N}_rate = count ÷ den(den=0 → null)。
|
||||
* - 复诊合计:visit_all_count = Σ_{N≥2} visit{N}_count;visit_all_den = assigned_total_count = |NI ∪ IH|;visit_all_rate 可超过 100%。
|
||||
* - 旧口径开关 inherit_mode=exclude|slot(默认 config project.revisit_rate.default_inherit_mode):
|
||||
* exclude 完全复现旧实现(IH=∅、不忽略 from==to、不豁免回退行,assigned_count/visit{N}_count/visit{N}_rate 与历史逐值一致);
|
||||
* 无论模式都输出 visit2_rate_new_assign = 二诊单数 ÷ 旧口径新指派数(供自动分配过渡期分档)。
|
||||
* - 分档动态产出:N 从 2 起,至 max(4, 当月分子最大诊次, 最大起算诊次)(上限 MAX_VISIT_SLOT),返回 `slots` 供前端动态渲染。
|
||||
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
||||
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
||||
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
||||
* - 部门行 / 合计行:NI 按诊单去重;IH = (∪IH(A)) \ NI_dept,entry 取部门内最小值;N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
||||
*/
|
||||
class RevisitRateLogic
|
||||
{
|
||||
@@ -41,10 +44,10 @@ class RevisitRateLogic
|
||||
private const UNASSIGNED_DEPT_NAME = '未分配部门';
|
||||
|
||||
/**
|
||||
* @param array{month?:string,dept_ids?:int[]|string} $params
|
||||
* @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params
|
||||
*
|
||||
* @return array{
|
||||
* month:string,start_date:string,end_date:string,
|
||||
* month:string,start_date:string,end_date:string,inherit_mode:string,
|
||||
* slots:list<int>,
|
||||
* total:array<string,int|float|null>,
|
||||
* rows:list<array<string,mixed>>
|
||||
@@ -62,22 +65,19 @@ class RevisitRateLogic
|
||||
'month' => $month,
|
||||
'start_date' => $ctx['startDate'],
|
||||
'end_date' => $ctx['endDate'],
|
||||
'inherit_mode' => $ctx['inheritMode'],
|
||||
'slots' => $minSlots,
|
||||
'total' => self::buildMetricPack(0, [], $minSlots),
|
||||
'total' => self::buildMetricPack(0, [], [], $minSlots, 0),
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// 分档:2 ~ max(4, 当月命中的最大诊次)
|
||||
$maxHitSlot = 0;
|
||||
foreach ($ctx['slotOrdersByAssistant'] as $slotMap) {
|
||||
foreach ($slotMap as $slot => $_) {
|
||||
if ((int) $slot > $maxHitSlot) {
|
||||
$maxHitSlot = (int) $slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
$slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot));
|
||||
// 分档:2 ~ max(4, 当月命中的最大诊次, 最大起算诊次),上限 MAX_VISIT_SLOT
|
||||
$slots = RevisitRateCalculator::slotRange(
|
||||
$ctx['slotOrdersByAssistant'],
|
||||
(int) $ctx['maxEntry'],
|
||||
self::MIN_VISIT_SLOT_CEILING
|
||||
);
|
||||
|
||||
$nameMap = Db::name('admin')
|
||||
->whereIn('id', array_keys($universe))
|
||||
@@ -86,22 +86,19 @@ class RevisitRateLogic
|
||||
// 医助行(按部门分组收集)
|
||||
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
|
||||
$assistantRowsByDept = [];
|
||||
/** @var array<int, array<int, true>> $deptDiagSet 部门 => 去重被指派诊单集合 */
|
||||
$deptDiagSet = [];
|
||||
/** @var array<int, list<int>> $deptAssistants 部门 => 医助 id 列表(部门行按诊单去重用) */
|
||||
$deptAssistants = [];
|
||||
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
|
||||
$deptSlotCounts = [];
|
||||
/** @var array<int, true> $totalDiagSet */
|
||||
$totalDiagSet = [];
|
||||
/** @var array<int, int> $totalSlotCounts */
|
||||
$totalSlotCounts = [];
|
||||
|
||||
foreach ($universe as $aid => $_) {
|
||||
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
|
||||
$deptAssistants[$deptId][] = (int) $aid;
|
||||
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
|
||||
foreach ($diagSet as $did => $_d) {
|
||||
$deptDiagSet[$deptId][$did] = true;
|
||||
$totalDiagSet[$did] = true;
|
||||
}
|
||||
$inheritEntries = $ctx['inheritEntryByAssistant'][$aid] ?? [];
|
||||
$excludeSet = $ctx['excludeDiagsByAssistant'][$aid] ?? [];
|
||||
$slotCounts = [];
|
||||
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
|
||||
$cnt = \count($orders);
|
||||
@@ -119,10 +116,10 @@ class RevisitRateLogic
|
||||
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
|
||||
'dept_id' => $deptId,
|
||||
'dept_name' => $deptName,
|
||||
] + self::buildMetricPack(\count($diagSet), $slotCounts, $slots);
|
||||
] + self::buildMetricPack(\count($diagSet), $inheritEntries, $slotCounts, $slots, \count($excludeSet));
|
||||
}
|
||||
|
||||
// 部门行 + 子行
|
||||
// 部门行 + 子行(NI 按诊单去重;IH = ∪IH(A) \ NI_dept,entry 取最小)
|
||||
$rows = [];
|
||||
foreach ($assistantRowsByDept as $deptId => $children) {
|
||||
usort($children, static function (array $a, array $b): int {
|
||||
@@ -135,6 +132,13 @@ class RevisitRateLogic
|
||||
$deptName = $deptId > 0
|
||||
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
||||
: self::UNASSIGNED_DEPT_NAME;
|
||||
$aids = $deptAssistants[$deptId] ?? [];
|
||||
[$deptNiSet, $deptIhEntries] = RevisitRateCalculator::mergeInheritEntries(
|
||||
$ctx['diagsByAssistant'],
|
||||
$ctx['inheritEntryByAssistant'],
|
||||
$aids
|
||||
);
|
||||
$deptExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $aids);
|
||||
$rows[] = [
|
||||
'row_key' => 'd' . $deptId,
|
||||
'is_dept' => 1,
|
||||
@@ -142,7 +146,13 @@ class RevisitRateLogic
|
||||
'dept_name' => $deptName,
|
||||
'assistant_count' => \count($children),
|
||||
'children' => $children,
|
||||
] + self::buildMetricPack(\count($deptDiagSet[$deptId] ?? []), $deptSlotCounts[$deptId] ?? [], $slots);
|
||||
] + self::buildMetricPack(
|
||||
\count($deptNiSet),
|
||||
$deptIhEntries,
|
||||
$deptSlotCounts[$deptId] ?? [],
|
||||
$slots,
|
||||
\count($deptExcludeSet)
|
||||
);
|
||||
}
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
if ($a['assigned_count'] !== $b['assigned_count']) {
|
||||
@@ -152,54 +162,174 @@ class RevisitRateLogic
|
||||
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
|
||||
});
|
||||
|
||||
$allAids = array_map('intval', array_keys($universe));
|
||||
[$totalNiSet, $totalIhEntries] = RevisitRateCalculator::mergeInheritEntries(
|
||||
$ctx['diagsByAssistant'],
|
||||
$ctx['inheritEntryByAssistant'],
|
||||
$allAids
|
||||
);
|
||||
$totalExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $allAids);
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'start_date' => $ctx['startDate'],
|
||||
'end_date' => $ctx['endDate'],
|
||||
'inherit_mode' => $ctx['inheritMode'],
|
||||
'slots' => $slots,
|
||||
'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots),
|
||||
'total' => self::buildMetricPack(
|
||||
\count($totalNiSet),
|
||||
$totalIhEntries,
|
||||
$totalSlotCounts,
|
||||
$slots,
|
||||
\count($totalExcludeSet)
|
||||
),
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。
|
||||
* 医助本人「我的 N 诊接诊率」(个人业绩页卡片,方案 §10 D1-h)。
|
||||
*
|
||||
* 复用 overview() 的全量结果(overview 不按个人数据范围过滤、仅限二中心子树),只从各部门行的 children 中
|
||||
* 挑出 assistant_id 匹配的医助行返回,不暴露其他医助数据;本人不在二中心子树或当月无任何指派 / 成交时 row=null,
|
||||
* in_scope 用于区分两种情况(false=非二中心医助)。结果按 month / assistant / mode 缓存 600 秒,缓存不可用时直接计算。
|
||||
*
|
||||
* @return array{month:string,inherit_mode:string,slots:list<int>,in_scope:bool,row:array<string,mixed>|null}
|
||||
*/
|
||||
public static function assistantOverview(int $assistantId, string $month, string $inheritMode = 'slot'): array
|
||||
{
|
||||
$month = self::normalizeMonth($month);
|
||||
$inheritMode = $inheritMode === 'exclude' ? 'exclude' : 'slot';
|
||||
$cacheKey = sprintf('revisit_rate:assistant_overview:%s:%d:%s', $month, $assistantId, $inheritMode);
|
||||
try {
|
||||
$cached = \think\facade\Cache::get($cacheKey);
|
||||
if (\is_array($cached) && isset($cached['month'], $cached['slots'])) {
|
||||
return $cached;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// 缓存驱动不可用:直接计算
|
||||
}
|
||||
|
||||
$overview = self::overview(['month' => $month, 'inherit_mode' => $inheritMode]);
|
||||
$row = null;
|
||||
if ($assistantId > 0) {
|
||||
foreach ($overview['rows'] as $deptRow) {
|
||||
foreach ((array) ($deptRow['children'] ?? []) as $child) {
|
||||
if ((int) ($child['assistant_id'] ?? 0) === $assistantId) {
|
||||
$row = $child;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 是否属于二中心子树:有行必然在子树内;无行时按人事部门判断,供前端区分「非二中心」与「当月无被指派患者」
|
||||
$inScope = $row !== null;
|
||||
if (!$inScope && $assistantId > 0) {
|
||||
[$assistantDept] = self::buildAssistantDeptIndex([$assistantId]);
|
||||
$deptId = (int) ($assistantDept[$assistantId] ?? 0);
|
||||
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||
$inScope = $deptId > 0 && isset($erSet[$deptId]);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'month' => (string) $overview['month'],
|
||||
'inherit_mode' => (string) $overview['inherit_mode'],
|
||||
'slots' => array_values(array_map('intval', (array) $overview['slots'])),
|
||||
'in_scope' => $inScope,
|
||||
'row' => $row,
|
||||
];
|
||||
try {
|
||||
\think\facade\Cache::set($cacheKey, $result, 600);
|
||||
} catch (\Throwable) {
|
||||
// 写缓存失败不影响返回
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 被指派明细(按诊单聚合,与「新指派数 / 继承数 / 被指派合计」同口径可对账)。
|
||||
* scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
|
||||
* kind:new=仅新指派行 / inherit=仅继承行 / all(默认)=两者;行字段 assign_type(new|inherit)、
|
||||
* inherit_visit_slot(继承时诊次 k,新指派为 null)、entry_slot(起算诊次;新指派恒为 2)。
|
||||
* 部门/合计 scope 下与 overview 去重规则一致:诊单只要对 scope 内任一医助为新指派即记为 new,
|
||||
* 否则为 inherit 且 entry 取 scope 内最小值;故 kind=all 的「new 行数 + entry<=N 的 inherit 行数」= visit{N}_den。
|
||||
*
|
||||
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
|
||||
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string,kind?:string} $params
|
||||
*
|
||||
* @return array{month:string,count:int,rows:list<array<string,mixed>>}
|
||||
* @return array{month:string,inherit_mode:string,kind:string,count:int,rows:list<array<string,mixed>>}
|
||||
*/
|
||||
public static function assignLines(array $params): array
|
||||
{
|
||||
$ctx = self::buildStatsCore($params);
|
||||
$assistantSet = self::applyRowScope($ctx, $params);
|
||||
$kind = strtolower(trim((string) ($params['kind'] ?? 'all')));
|
||||
if (!\in_array($kind, ['new', 'inherit', 'all'], true)) {
|
||||
$kind = 'all';
|
||||
}
|
||||
$minNext = self::inheritMinNextSlot();
|
||||
|
||||
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $byDiag */
|
||||
$byDiag = [];
|
||||
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $niByDiag */
|
||||
$niByDiag = [];
|
||||
foreach ($ctx['pairsRaw'] as $p) {
|
||||
$aid = (int) $p['to_assistant_id'];
|
||||
$did = (int) $p['diagnosis_id'];
|
||||
if (!isset($assistantSet[$aid])) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($byDiag[$did])) {
|
||||
$byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
|
||||
if (!isset($niByDiag[$did])) {
|
||||
$niByDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
|
||||
}
|
||||
$byDiag[$did]['assistants'][$aid] = true;
|
||||
$byDiag[$did]['assign_count']++;
|
||||
$byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']);
|
||||
$niByDiag[$did]['assistants'][$aid] = true;
|
||||
$niByDiag[$did]['assign_count']++;
|
||||
$niByDiag[$did]['last_time'] = max($niByDiag[$did]['last_time'], (int) $p['create_time']);
|
||||
}
|
||||
|
||||
if ($byDiag === []) {
|
||||
return ['month' => $ctx['month'], 'count' => 0, 'rows' => []];
|
||||
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int, k: int, entry: int, is_rollback: int}> $ihByDiag */
|
||||
$ihByDiag = [];
|
||||
foreach ($ctx['inheritPairs'] as $p) {
|
||||
$aid = (int) $p['to_assistant_id'];
|
||||
$did = (int) $p['diagnosis_id'];
|
||||
if (!isset($assistantSet[$aid]) || isset($niByDiag[$did])) {
|
||||
continue;
|
||||
}
|
||||
$entry = (int) $p['entry_slot'];
|
||||
if (!isset($ihByDiag[$did])) {
|
||||
$ihByDiag[$did] = [
|
||||
'assistants' => [],
|
||||
'assign_count' => 0,
|
||||
'last_time' => 0,
|
||||
'k' => (int) $p['inherit_visit_slot'],
|
||||
'entry' => $entry,
|
||||
'is_rollback' => (int) ($p['is_rollback'] ?? 0),
|
||||
];
|
||||
} elseif ($entry < $ihByDiag[$did]['entry']) {
|
||||
$ihByDiag[$did]['entry'] = $entry;
|
||||
$ihByDiag[$did]['k'] = (int) $p['inherit_visit_slot'];
|
||||
$ihByDiag[$did]['is_rollback'] = (int) ($p['is_rollback'] ?? 0);
|
||||
}
|
||||
$ihByDiag[$did]['assistants'][$aid] = true;
|
||||
$ihByDiag[$did]['assign_count'] += (int) ($p['assign_count'] ?? 1);
|
||||
$ihByDiag[$did]['last_time'] = max($ihByDiag[$did]['last_time'], (int) $p['create_time']);
|
||||
}
|
||||
|
||||
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
|
||||
if ($kind === 'new') {
|
||||
$ihByDiag = [];
|
||||
} elseif ($kind === 'inherit') {
|
||||
$niByDiag = [];
|
||||
}
|
||||
if ($niByDiag === [] && $ihByDiag === []) {
|
||||
return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => 0, 'rows' => []];
|
||||
}
|
||||
|
||||
$diagInfo = self::fetchDiagnosisInfo(array_values(array_unique(array_merge(array_keys($niByDiag), array_keys($ihByDiag)))));
|
||||
$assistantIds = [];
|
||||
foreach ($byDiag as $d) {
|
||||
foreach ($d['assistants'] as $aid => $_) {
|
||||
$assistantIds[$aid] = true;
|
||||
foreach ([$niByDiag, $ihByDiag] as $map) {
|
||||
foreach ($map as $d) {
|
||||
foreach ($d['assistants'] as $aid => $_) {
|
||||
$assistantIds[$aid] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$nameMap = Db::name('admin')
|
||||
@@ -207,7 +337,7 @@ class RevisitRateLogic
|
||||
->column('name', 'id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($byDiag as $did => $d) {
|
||||
foreach ($niByDiag as $did => $d) {
|
||||
$names = [];
|
||||
foreach ($d['assistants'] as $aid => $_) {
|
||||
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
||||
@@ -220,24 +350,50 @@ class RevisitRateLogic
|
||||
'assign_count' => (int) $d['assign_count'],
|
||||
'last_assign_time' => (int) $d['last_time'],
|
||||
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
|
||||
'assign_type' => 'new',
|
||||
'assign_type_text' => '新指派',
|
||||
'inherit_visit_slot' => null,
|
||||
'entry_slot' => $minNext,
|
||||
'is_rollback' => 0,
|
||||
];
|
||||
}
|
||||
foreach ($ihByDiag as $did => $d) {
|
||||
$names = [];
|
||||
foreach ($d['assistants'] as $aid => $_) {
|
||||
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
||||
}
|
||||
$rows[] = [
|
||||
'diagnosis_id' => (int) $did,
|
||||
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
|
||||
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
|
||||
'assistant_names' => implode('、', $names),
|
||||
'assign_count' => (int) $d['assign_count'],
|
||||
'last_assign_time' => (int) $d['last_time'],
|
||||
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
|
||||
'assign_type' => 'inherit',
|
||||
'assign_type_text' => $d['is_rollback'] === 1 ? '继承(回退)' : '继承',
|
||||
'inherit_visit_slot' => (int) $d['k'],
|
||||
'entry_slot' => (int) $d['entry'],
|
||||
'is_rollback' => (int) $d['is_rollback'],
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
|
||||
|
||||
return ['month' => $ctx['month'], 'count' => \count($rows), 'rows' => $rows];
|
||||
return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => \count($rows), 'rows' => $rows];
|
||||
}
|
||||
|
||||
/**
|
||||
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
|
||||
* slot=0 表示全部 N>=2(与 visit_all_count 对账),行附 visit_slot。
|
||||
*
|
||||
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
|
||||
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string} $params
|
||||
*
|
||||
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
|
||||
*/
|
||||
public static function visitOrderLines(array $params): array
|
||||
{
|
||||
$slot = (int) ($params['slot'] ?? 0);
|
||||
if ($slot < 2 || $slot > self::MAX_VISIT_SLOT) {
|
||||
if ($slot < 0 || $slot === 1 || $slot > self::MAX_VISIT_SLOT) {
|
||||
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||
}
|
||||
|
||||
@@ -246,9 +402,16 @@ class RevisitRateLogic
|
||||
|
||||
$orderRows = [];
|
||||
foreach ($assistantSet as $aid => $_) {
|
||||
foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $r) {
|
||||
$r['holder_assistant_id'] = (int) $aid;
|
||||
$orderRows[] = $r;
|
||||
$slotMap = $ctx['slotOrdersByAssistant'][$aid] ?? [];
|
||||
if ($slot > 0) {
|
||||
$slotMap = isset($slotMap[$slot]) ? [$slot => $slotMap[$slot]] : [];
|
||||
}
|
||||
foreach ($slotMap as $s => $orders) {
|
||||
foreach ($orders as $r) {
|
||||
$r['holder_assistant_id'] = (int) $aid;
|
||||
$r['visit_slot'] = (int) $s;
|
||||
$orderRows[] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($orderRows === []) {
|
||||
@@ -290,6 +453,7 @@ class RevisitRateLogic
|
||||
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
|
||||
'assistant_id' => $hid,
|
||||
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—',
|
||||
'visit_slot' => (int) ($r['visit_slot'] ?? 0),
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
|
||||
@@ -331,17 +495,22 @@ class RevisitRateLogic
|
||||
/**
|
||||
* 核心统计上下文:
|
||||
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
||||
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
||||
* 2. 分母:当月新指派 NI 与继承 IH 的「医助 × 诊单」(RevisitRateCalculator),再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
||||
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
|
||||
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
|
||||
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内);
|
||||
* 继承集合与旧口径集合与分母/分子一并过滤(当月仅有继承行的医助不会静默丢失、也不会逃过二中心过滤)。
|
||||
*
|
||||
* @param array{month?:string,dept_ids?:int[]|string} $params
|
||||
* @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params
|
||||
*
|
||||
* @return array{
|
||||
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,
|
||||
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,create_time:int}>,
|
||||
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,inheritMode:string,
|
||||
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int}>,
|
||||
* diagsByAssistant:array<int,array<int,true>>,
|
||||
* excludeDiagsByAssistant:array<int,array<int,true>>,
|
||||
* inheritEntryByAssistant:array<int,array<int,int>>,
|
||||
* inheritPairs:list<array<string,mixed>>,
|
||||
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
|
||||
* maxEntry:int,
|
||||
* assistantDept:array<int,int>,
|
||||
* deptNames:array<int,string>
|
||||
* }
|
||||
@@ -351,125 +520,90 @@ class RevisitRateLogic
|
||||
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
|
||||
$startTs = (int) strtotime($month . '-01 00:00:00');
|
||||
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
|
||||
$inheritMode = self::resolveInheritMode($params);
|
||||
|
||||
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
|
||||
// inherit_visit_slot 为写入时快照,仅供明细展示;统计一律以实时重算为准
|
||||
$logRows = Db::name('tcm_diagnosis_assign_log')
|
||||
->alias('lg')
|
||||
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||
->where('lg.create_time', '<=', $endTs)
|
||||
->where('lg.diagnosis_id', '>', 0)
|
||||
->field(['lg.id', 'lg.diagnosis_id', 'lg.to_assistant_id', 'lg.create_time', 'lg.is_inherit'])
|
||||
->field([
|
||||
'lg.id', 'lg.diagnosis_id', 'lg.from_assistant_id', 'lg.to_assistant_id', 'lg.operator_admin_id',
|
||||
'lg.create_time', 'lg.is_inherit', 'lg.inherit_visit_slot',
|
||||
])
|
||||
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序) */
|
||||
$timeline = [];
|
||||
$pairsRaw = [];
|
||||
/** @var array<int, array<int, true>> $diagsByAssistant 分母:医助 => 诊单集合 */
|
||||
$diagsByAssistant = [];
|
||||
/** @var array<int, true> $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */
|
||||
$candidateDiagSet = [];
|
||||
foreach ($logRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
$aid = (int) ($r['to_assistant_id'] ?? 0);
|
||||
$t = (int) ($r['create_time'] ?? 0);
|
||||
$timeline[$did][] = ['t' => $t, 'to' => $aid];
|
||||
if ($aid > 0) {
|
||||
$candidateDiagSet[$did] = true;
|
||||
if ((int) ($r['is_inherit'] ?? 0) === 0 && $t >= $startTs && $t <= $endTs) {
|
||||
$pairsRaw[] = ['diagnosis_id' => $did, 'to_assistant_id' => $aid, 'create_time' => $t];
|
||||
$diagsByAssistant[$aid][$did] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 分母剔除集合:当月被指派(含继承)诊单中名下存在拒收(9)/退款(10) 业务订单者(与明细 assignLines 同口径)
|
||||
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(
|
||||
RevisitRateCalculator::monthAssignedDiagIds($logRows, $startTs, $endTs)
|
||||
);
|
||||
// 历史「回退自动分配」行(slot 模式按继承处理)
|
||||
$rollbackKeySet = $inheritMode === RevisitRateCalculator::MODE_SLOT
|
||||
? self::fetchRollbackKeySet($startTs, $endTs)
|
||||
: [];
|
||||
|
||||
// 分母:剔除名下存在拒收(9)/退款(10) 业务订单的诊单(与明细 assignLines 同口径)
|
||||
$assignedDiagIds = [];
|
||||
foreach ($diagsByAssistant as $diagSet) {
|
||||
foreach ($diagSet as $did => $_) {
|
||||
$assignedDiagIds[(int) $did] = true;
|
||||
}
|
||||
}
|
||||
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(array_keys($assignedDiagIds));
|
||||
if ($refundRejectDiagSet !== []) {
|
||||
foreach ($diagsByAssistant as $aid => $diagSet) {
|
||||
foreach ($diagSet as $did => $_) {
|
||||
if (isset($refundRejectDiagSet[$did])) {
|
||||
unset($diagsByAssistant[$aid][$did]);
|
||||
}
|
||||
}
|
||||
if ($diagsByAssistant[$aid] === []) {
|
||||
unset($diagsByAssistant[$aid]);
|
||||
}
|
||||
}
|
||||
$pairsRaw = array_values(array_filter(
|
||||
$pairsRaw,
|
||||
static fn (array $p): bool => !isset($refundRejectDiagSet[(int) $p['diagnosis_id']])
|
||||
));
|
||||
}
|
||||
// 分子:曾被指派诊单的订单序列(分块生成器,同一诊单连续),统计诊次 = 实单全局序号 + 诊单偏移
|
||||
$candidateDiagIds = RevisitRateCalculator::candidateDiagIds($logRows);
|
||||
$offsetMap = self::fetchRevisitSlotStartOffsetMap($candidateDiagIds);
|
||||
$orderRows = self::iterateOrderSeqRows(
|
||||
$candidateDiagIds,
|
||||
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
|
||||
);
|
||||
|
||||
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 0 → 第 1 笔实单为一诊)
|
||||
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
|
||||
$slotOrdersByAssistant = [];
|
||||
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
|
||||
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
|
||||
$orderRows = self::fetchOrderSeqRows(
|
||||
$chunk,
|
||||
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
|
||||
);
|
||||
$curDid = 0;
|
||||
$seq = 0;
|
||||
$ptr = 0;
|
||||
$holder = 0;
|
||||
$offset = 0;
|
||||
foreach ($orderRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($did !== $curDid) {
|
||||
$curDid = $did;
|
||||
$seq = 0;
|
||||
$ptr = 0;
|
||||
$holder = 0;
|
||||
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||
}
|
||||
$seq++;
|
||||
$effectiveSlot = $seq + $offset;
|
||||
$ct = (int) ($r['create_time'] ?? 0);
|
||||
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
||||
$tl = $timeline[$did] ?? [];
|
||||
$tlCount = \count($tl);
|
||||
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
|
||||
$holder = (int) $tl[$ptr]['to'];
|
||||
$ptr++;
|
||||
}
|
||||
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
|
||||
continue;
|
||||
}
|
||||
if ($ct < $startTs || $ct > $endTs) {
|
||||
continue;
|
||||
}
|
||||
if ($holder > 0) {
|
||||
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
$calc = RevisitRateCalculator::compute(
|
||||
$logRows,
|
||||
$orderRows,
|
||||
$offsetMap,
|
||||
$refundRejectDiagSet,
|
||||
$rollbackKeySet,
|
||||
$startTs,
|
||||
$endTs,
|
||||
$inheritMode,
|
||||
self::inheritMinNextSlot()
|
||||
);
|
||||
unset($logRows);
|
||||
$pairsRaw = $calc['pairsRaw'];
|
||||
$diagsByAssistant = $calc['diagsByAssistant'];
|
||||
$excludeDiagsByAssistant = $calc['excludeDiagsByAssistant'];
|
||||
$inheritEntryByAssistant = $calc['inheritEntryByAssistant'];
|
||||
$inheritPairs = $calc['inheritPairs'];
|
||||
$slotOrdersByAssistant = $calc['slotOrdersByAssistant'];
|
||||
|
||||
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
||||
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
||||
$universeIds = array_keys(
|
||||
$diagsByAssistant + $slotOrdersByAssistant + $inheritEntryByAssistant + $excludeDiagsByAssistant
|
||||
);
|
||||
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
||||
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
|
||||
if ($subtreeSet === []) {
|
||||
// 无二中心部门时整表为空,避免误展示其它中心数据
|
||||
$diagsByAssistant = [];
|
||||
$excludeDiagsByAssistant = [];
|
||||
$inheritEntryByAssistant = [];
|
||||
$slotOrdersByAssistant = [];
|
||||
} else {
|
||||
foreach ($universeIds as $aid) {
|
||||
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
||||
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
|
||||
unset($diagsByAssistant[$aid], $slotOrdersByAssistant[$aid]);
|
||||
unset(
|
||||
$diagsByAssistant[$aid],
|
||||
$excludeDiagsByAssistant[$aid],
|
||||
$inheritEntryByAssistant[$aid],
|
||||
$slotOrdersByAssistant[$aid]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 列范围上界只看过滤后仍在表内的继承行
|
||||
$maxEntry = 0;
|
||||
foreach ($inheritEntryByAssistant as $entries) {
|
||||
foreach ($entries as $entry) {
|
||||
if ((int) $entry <= self::MAX_VISIT_SLOT && (int) $entry > $maxEntry) {
|
||||
$maxEntry = (int) $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,29 +614,95 @@ class RevisitRateLogic
|
||||
'endDate' => date('Y-m-d', $endTs),
|
||||
'startTs' => $startTs,
|
||||
'endTs' => $endTs,
|
||||
'inheritMode' => $inheritMode,
|
||||
'pairsRaw' => $pairsRaw,
|
||||
'diagsByAssistant' => $diagsByAssistant,
|
||||
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
|
||||
'inheritEntryByAssistant' => $inheritEntryByAssistant,
|
||||
'inheritPairs' => $inheritPairs,
|
||||
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
||||
'maxEntry' => $maxEntry,
|
||||
'assistantDept' => $assistantDept,
|
||||
'deptNames' => $deptNames,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计涉及的医助全集:分母(被指派)∪ 分子(持有成交)。
|
||||
* 口径白名单:请求参数 inherit_mode=exclude|slot,缺省取 config project.revisit_rate.default_inherit_mode(再缺省 slot)。
|
||||
*/
|
||||
private static function resolveInheritMode(array $params): string
|
||||
{
|
||||
$default = (string) (config('project.revisit_rate.default_inherit_mode') ?? RevisitRateCalculator::MODE_SLOT);
|
||||
|
||||
return RevisitRateCalculator::normalizeMode($params['inherit_mode'] ?? '', $default);
|
||||
}
|
||||
|
||||
/** 继承起算诊次下限(config project.revisit_rate.inherit_min_next_slot,默认 2,最小 2) */
|
||||
private static function inheritMinNextSlot(): int
|
||||
{
|
||||
$v = (int) (config('project.revisit_rate.inherit_min_next_slot') ?? RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT);
|
||||
|
||||
return max(RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT, $v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当月「回退自动分配」键集合:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) 与
|
||||
* assign_log(diagnosis_id, create_time, operator_admin_id) 一一对应(AutoAssignLogLogic::rollbackOne 同一事务同一 $now)。
|
||||
*
|
||||
* @param array{diagsByAssistant:array<int,array<int,true>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private static function fetchRollbackKeySet(int $startTs, int $endTs): array
|
||||
{
|
||||
$rows = Db::name('tcm_diagnosis_auto_assign_log')
|
||||
->where('rollback_time', '>=', $startTs)
|
||||
->where('rollback_time', '<=', $endTs)
|
||||
->where('diagnosis_id', '>', 0)
|
||||
->field(['diagnosis_id', 'rollback_time', 'rollback_admin_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[RevisitRateCalculator::rollbackKey(
|
||||
(int) ($r['diagnosis_id'] ?? 0),
|
||||
(int) ($r['rollback_time'] ?? 0),
|
||||
(int) ($r['rollback_admin_id'] ?? 0)
|
||||
)] = true;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单序列分块生成器:每块按诊单 id 切分(一个诊单的订单全部落在同一块),块内已按 (diagnosis_id, create_time, id) 升序。
|
||||
*
|
||||
* @param list<int> $diagIds
|
||||
* @param list<string> $fields
|
||||
*
|
||||
* @return \Generator<int, array<string, mixed>>
|
||||
*/
|
||||
private static function iterateOrderSeqRows(array $diagIds, array $fields): \Generator
|
||||
{
|
||||
foreach (array_chunk($diagIds, 2000) as $chunk) {
|
||||
foreach (self::fetchOrderSeqRows($chunk, $fields) as $r) {
|
||||
yield $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计涉及的医助全集:新指派 ∪ 继承 ∪ 旧口径新指派 ∪ 分子(持有成交)。
|
||||
*
|
||||
* @param array{diagsByAssistant:array<int,array<int,true>>,excludeDiagsByAssistant:array<int,array<int,true>>,inheritEntryByAssistant:array<int,array<int,int>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private static function assistantUniverse(array $ctx): array
|
||||
{
|
||||
$set = [];
|
||||
foreach (array_keys($ctx['diagsByAssistant']) as $aid) {
|
||||
$set[(int) $aid] = true;
|
||||
}
|
||||
foreach (array_keys($ctx['slotOrdersByAssistant']) as $aid) {
|
||||
$set[(int) $aid] = true;
|
||||
foreach (['diagsByAssistant', 'excludeDiagsByAssistant', 'inheritEntryByAssistant', 'slotOrdersByAssistant'] as $key) {
|
||||
foreach (array_keys($ctx[$key] ?? []) as $aid) {
|
||||
$set[(int) $aid] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $set;
|
||||
@@ -715,17 +915,8 @@ class RevisitRateLogic
|
||||
*/
|
||||
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
|
||||
{
|
||||
$q = Db::name('tcm_prescription_order')
|
||||
->alias('o')
|
||||
->whereIn('o.diagnosis_id', $diagIds)
|
||||
->whereNull('o.delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||
|
||||
return $q
|
||||
->field($fields)
|
||||
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||
return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -735,21 +926,8 @@ class RevisitRateLogic
|
||||
*/
|
||||
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
|
||||
{
|
||||
if ($diagIds === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (array_chunk($diagIds, 2000) as $chunk) {
|
||||
$rows = Db::name('tcm_diagnosis')
|
||||
->whereIn('id', $chunk)
|
||||
->whereNull('delete_time')
|
||||
->column('revisit_slot_start_offset', 'id');
|
||||
foreach ($rows as $id => $offset) {
|
||||
$out[(int) $id] = (int) $offset;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||
return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -759,15 +937,8 @@ class RevisitRateLogic
|
||||
*/
|
||||
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
|
||||
{
|
||||
$offset = (int) ($offsetMap[$diagId] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
if ($offset > 20) {
|
||||
$offset = 20;
|
||||
}
|
||||
|
||||
return $offset;
|
||||
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||
return RevisitVisitSlotService::resolveRevisitSlotStartOffset($diagId, $offsetMap);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -797,23 +968,24 @@ class RevisitRateLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $slotCounts [slot => n]
|
||||
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
||||
* 指标包:assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、
|
||||
* visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(实现见 RevisitRateCalculator::metricPack)。
|
||||
*
|
||||
* @param array<int, int> $inheritEntryByDiag 诊单 => 起算诊次
|
||||
* @param array<int, int> $slotCounts [slot => n]
|
||||
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
||||
* @param int $newAssignedExclude 旧口径(:380 现状)新指派数,供 visit2_rate_new_assign
|
||||
*
|
||||
* @return array<string, int|float|null>
|
||||
*/
|
||||
private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array
|
||||
{
|
||||
$pack = ['assigned_count' => $assigned];
|
||||
foreach ($slots as $slot) {
|
||||
$cnt = (int) ($slotCounts[$slot] ?? 0);
|
||||
$pack['visit' . $slot . '_count'] = $cnt;
|
||||
$pack['visit' . $slot . '_rate'] = $assigned > 0
|
||||
? round($cnt / $assigned * 100, 2)
|
||||
: null;
|
||||
}
|
||||
|
||||
return $pack;
|
||||
private static function buildMetricPack(
|
||||
int $newAssigned,
|
||||
array $inheritEntryByDiag,
|
||||
array $slotCounts,
|
||||
array $slots,
|
||||
int $newAssignedExclude
|
||||
): array {
|
||||
return RevisitRateCalculator::metricPack($newAssigned, $inheritEntryByDiag, $slotCounts, $slots, $newAssignedExclude);
|
||||
}
|
||||
|
||||
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
|
||||
|
||||
@@ -22,7 +22,8 @@ use think\facade\Db;
|
||||
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
||||
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
|
||||
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
|
||||
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面「当月被指派总数」完全同口径(见 RevisitRateLogic)
|
||||
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面的『新指派数』(assigned_count) 同口径(见 RevisitRateLogic;
|
||||
* 接诊率 v2 另有继承分母 inherit_count,本看板不纳入——方案 D1-g)
|
||||
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
|
||||
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
||||
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
||||
@@ -856,9 +857,9 @@ class YejiStatsLogic
|
||||
|
||||
$rangeNote = $channelFilterActive
|
||||
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
||||
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
|
||||
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。被指派数与复诊接诊率『新指派数』同口径(不含继承)。'
|
||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派,与复诊接诊率『新指派数』同口径)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
||||
|
||||
$leaderboards = [];
|
||||
@@ -1780,7 +1781,7 @@ class YejiStatsLogic
|
||||
/**
|
||||
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
|
||||
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
|
||||
* 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
||||
* 与「复诊接诊率」页面『新指派数』(assigned_count) 同口径(不含接诊率 v2 的继承分母)。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
||||
*
|
||||
* @param array<int, int> $adminToPrimary
|
||||
* @param int[]|null $tagDiagIds
|
||||
@@ -1871,7 +1872,7 @@ class YejiStatsLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1),按 to_assistant_id 汇总(供排行榜)。
|
||||
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1,与复诊接诊率『新指派数』同口径),按 to_assistant_id 汇总(供排行榜)。
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
@@ -2617,7 +2618,7 @@ class YejiStatsLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。
|
||||
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重;即复诊接诊率『新指派数』口径)。
|
||||
*
|
||||
* @param array{
|
||||
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
|
||||
@@ -2910,7 +2911,7 @@ class YejiStatsLogic
|
||||
}
|
||||
|
||||
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
|
||||
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。';
|
||||
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单;与复诊接诊率『新指派数』(assigned_count) 同口径,不含继承。';
|
||||
|
||||
return [
|
||||
'start_date' => (string) $c['startDate'],
|
||||
|
||||
@@ -38,6 +38,9 @@ use app\common\service\AppointmentCallPolicy;
|
||||
use app\common\service\FileService;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\storage\Driver as StorageDriver;
|
||||
use app\common\service\tcm\RevisitDueHook;
|
||||
use app\common\service\tcm\RevisitDueService;
|
||||
use app\common\service\tcm\RevisitVisitSlotService;
|
||||
use think\facade\Db;
|
||||
/**
|
||||
* 中医辨房病因诊单逻辑
|
||||
@@ -260,6 +263,10 @@ class DiagnosisLogic extends BaseLogic
|
||||
{
|
||||
try {
|
||||
Diagnosis::destroy($params['id']);
|
||||
// 诊单删除后把复诊到期快照推进为失效(Hook 内部吞异常,不影响删除结果)
|
||||
foreach ((array) $params['id'] as $deletedId) {
|
||||
RevisitDueHook::onDiagnosisChanged((int) $deletedId, 'delete');
|
||||
}
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
@@ -707,6 +714,15 @@ class DiagnosisLogic extends BaseLogic
|
||||
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
|
||||
$fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id);
|
||||
}
|
||||
// from == to(指派给自己)不硬拦:批量指派流程不中断,复诊接诊率 slot 口径统计时忽略该行;前端弹窗负责提示
|
||||
|
||||
// 继承时患者所处诊次快照(继承时刻之前的有效实单数 + 偏移;统计以实时重算为准,本列供指派记录面板展示/对账)
|
||||
$inheritVisitSlot = 0;
|
||||
$inheritVisitSlotCalcTime = 0;
|
||||
if ($isInherit === 1) {
|
||||
$inheritVisitSlot = min(255, max(0, RevisitVisitSlotService::slotAtTime($id, $now)));
|
||||
$inheritVisitSlotCalcTime = $now;
|
||||
}
|
||||
|
||||
Db::name('tcm_diagnosis')
|
||||
->where('id', $id)
|
||||
@@ -720,17 +736,19 @@ class DiagnosisLogic extends BaseLogic
|
||||
$admin = $req->adminInfo ?? [];
|
||||
// Db::insert 直写:与 assignLogList 一致,避免模型 allowField/schema 丢列
|
||||
Db::name('tcm_diagnosis_assign_log')->insert([
|
||||
'diagnosis_id' => $id,
|
||||
'from_assistant_id' => $fromAssistantId,
|
||||
'to_assistant_id' => $toAssistantId,
|
||||
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
|
||||
'operator_name' => (string) ($admin['name'] ?? ''),
|
||||
'operator_account' => (string) ($admin['account'] ?? ''),
|
||||
'ip' => (string) ($req->ip() ?? ''),
|
||||
'related_po_creator_id' => $relatedPoCreatorId,
|
||||
'related_po_create_time' => $relatedPoCreateTime,
|
||||
'is_inherit' => $isInherit,
|
||||
'create_time' => $now,
|
||||
'diagnosis_id' => $id,
|
||||
'from_assistant_id' => $fromAssistantId,
|
||||
'to_assistant_id' => $toAssistantId,
|
||||
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
|
||||
'operator_name' => (string) ($admin['name'] ?? ''),
|
||||
'operator_account' => (string) ($admin['account'] ?? ''),
|
||||
'ip' => (string) ($req->ip() ?? ''),
|
||||
'related_po_creator_id' => $relatedPoCreatorId,
|
||||
'related_po_create_time' => $relatedPoCreateTime,
|
||||
'is_inherit' => $isInherit,
|
||||
'inherit_visit_slot' => $inheritVisitSlot,
|
||||
'inherit_visit_slot_calc_time' => $inheritVisitSlotCalcTime,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
@@ -741,6 +759,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
|
||||
Db::commit();
|
||||
|
||||
// 事务提交后刷新复诊到期快照(换医助重置延迟字段;钩子内部吞异常,不影响指派结果)
|
||||
RevisitDueHook::onAssistantChanged($id, $toAssistantId, 'assign');
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
@@ -785,6 +806,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
try {
|
||||
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
|
||||
|
||||
// 起始偏移影响 current_slot/next_slot:刷新复诊到期快照(钩子内部吞异常)
|
||||
RevisitDueHook::onDiagnosisChanged($diagnosisId, 'offset');
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
@@ -849,6 +873,16 @@ class DiagnosisLogic extends BaseLogic
|
||||
$inherit = (int) ($r['is_inherit'] ?? 0);
|
||||
$r['is_inherit'] = $inherit;
|
||||
$r['is_inherit_text'] = $inherit === 1 ? '是' : '否';
|
||||
// 继承时诊次快照(2026-10 加列前的历史继承行 calc_time=0,无法回溯,文案留空)
|
||||
$k = (int) ($r['inherit_visit_slot'] ?? 0);
|
||||
$kCalcTime = (int) ($r['inherit_visit_slot_calc_time'] ?? 0);
|
||||
$r['inherit_visit_slot'] = $k;
|
||||
$r['inherit_visit_slot_calc_time'] = $kCalcTime;
|
||||
if ($inherit === 1 && $kCalcTime > 0) {
|
||||
$r['inherit_visit_slot_text'] = $k > 0 ? sprintf('继承时第 %d 诊', $k) : '继承时未成交';
|
||||
} else {
|
||||
$r['inherit_visit_slot_text'] = '';
|
||||
}
|
||||
}
|
||||
unset($r);
|
||||
|
||||
@@ -4281,6 +4315,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
|
||||
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null;
|
||||
|
||||
// 7) 复诊到期(契约 C,与诊单列表行 revisit_due 同构;无快照且无法计算时为 state='none')
|
||||
$revisitDue = RevisitDueService::buildListPayload([$diagnosisId], ['self_heal' => true, 'page_size' => 1])[$diagnosisId] ?? null;
|
||||
|
||||
return [
|
||||
'appointment' => $appointment,
|
||||
'diagnosis' => $diagnosis,
|
||||
@@ -4288,6 +4325,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
'tracking_notes' => $trackingNotes,
|
||||
'unserved_days' => $unservedDays,
|
||||
'last_blood_record_at' => $lastBloodRecordAt,
|
||||
'revisit_due' => $revisitDue,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
|
||||
use app\common\service\pharmacy\PharmacySubmissionClaimService;
|
||||
use app\common\service\pharmacy\PharmacySubmissionClaimWorkflow;
|
||||
use app\common\service\pharmacy\PharmacySupplyMode;
|
||||
use app\common\service\tcm\RevisitDueHook;
|
||||
use think\db\Query;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
@@ -979,7 +980,7 @@ class PrescriptionOrderLogic
|
||||
{
|
||||
self::$error = '';
|
||||
try {
|
||||
return Db::transaction(static function () use ($params, $adminId, $adminInfo) {
|
||||
$created = Db::transaction(static function () use ($params, $adminId, $adminInfo) {
|
||||
// 先锁始终存在的处方行;空订单集合也能串行化两次创建。
|
||||
// 首个一致性读发生在获得该锁后,等待者可见前一次创建提交的订单。
|
||||
$rx = Prescription::where('id', (int) ($params['prescription_id'] ?? 0))
|
||||
@@ -998,6 +999,13 @@ class PrescriptionOrderLogic
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 事务提交后刷新复诊到期快照(新订单开启新周期;钩子内部吞异常,不影响创建结果)
|
||||
if (is_array($created)) {
|
||||
RevisitDueHook::onOrderChanged((int) ($created['diagnosis_id'] ?? 0), (int) ($created['id'] ?? 0), 'create');
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
private static function createLocked(array $params, int $adminId, array $adminInfo)
|
||||
@@ -1165,7 +1173,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
try {
|
||||
return Db::transaction(static function () use ($params, $adminId, $adminInfo) {
|
||||
$edited = Db::transaction(static function () use ($params, $adminId, $adminInfo) {
|
||||
$order = PrescriptionOrder::where('id', (int) $params['id'])
|
||||
->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$order) {
|
||||
@@ -1192,6 +1200,13 @@ class PrescriptionOrderLogic
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 事务提交后刷新复诊到期快照:下单时间影响锚点/诊次;返回体不含 diagnosis_id,由钩子按订单 id 反查
|
||||
if (is_array($edited)) {
|
||||
RevisitDueHook::onOrderChanged(0, (int) ($edited['id'] ?? 0), 'edit_time');
|
||||
}
|
||||
|
||||
return $edited;
|
||||
}
|
||||
|
||||
public static function detail(int $id, int $adminId, array $adminInfo): ?array
|
||||
@@ -2251,7 +2266,7 @@ class PrescriptionOrderLogic
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$withdrawn = LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::withdrawLocked($id, $adminId, $adminInfo)
|
||||
);
|
||||
@@ -2261,6 +2276,13 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 事务提交后刷新复诊到期快照(撤回置 4,订单退出有效口径;钩子内部吞异常)
|
||||
if (is_array($withdrawn)) {
|
||||
RevisitDueHook::onOrderChanged((int) ($withdrawn['diagnosis_id'] ?? 0), $id, 'withdraw');
|
||||
}
|
||||
|
||||
return $withdrawn;
|
||||
}
|
||||
|
||||
private static function withdrawLocked(int $id, int $adminId, array $adminInfo)
|
||||
@@ -3303,6 +3325,9 @@ class PrescriptionOrderLogic
|
||||
$logLine
|
||||
);
|
||||
|
||||
// 刷新复诊到期快照(9/10 退出有效口径,终态可能释放医助;钩子内部吞异常)
|
||||
RevisitDueHook::onOrderChanged((int) $order->diagnosis_id, $id, 'complete:' . $targetFulfillmentStatus);
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
@@ -3478,6 +3503,9 @@ class PrescriptionOrderLogic
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'refund', $logLine);
|
||||
|
||||
// 刷新复诊到期快照(全额退款置 10 退出有效口径并可能释放医助;钩子内部吞异常)
|
||||
RevisitDueHook::onOrderChanged((int) $order->diagnosis_id, $id, 'refund');
|
||||
|
||||
$out = $order->toArray();
|
||||
$out['this_refund_amount'] = $refundAmount;
|
||||
$out['refund_full'] = $isFullRefund;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\validate\tcm;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 复诊到期弹窗写接口验证(snooze / snoozeAll / contact / close)与主管视图流水(logs)
|
||||
*
|
||||
* 归属(本人 / 超管 / 数据范围)与业务约束(承诺日范围、周期状态)在 RevisitDueLogic 内校验,此处只做入参形态。
|
||||
*
|
||||
* @package app\adminapi\validate\tcm
|
||||
*/
|
||||
class RevisitDueValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'id' => 'require|number|gt:0',
|
||||
'ids' => 'require|array',
|
||||
'due_id' => 'require|number|gt:0',
|
||||
'promised_date' => 'require|dateFormat:Y-m-d',
|
||||
'reason' => 'require|in:lost,other',
|
||||
'remark' => 'max:200',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'id.require' => '参数缺失',
|
||||
'id.number' => '提醒 id 非法',
|
||||
'id.gt' => '提醒 id 非法',
|
||||
'due_id.require' => '参数缺失 due_id',
|
||||
'due_id.number' => '提醒 id 非法',
|
||||
'due_id.gt' => '提醒 id 非法',
|
||||
'ids.require' => '请选择要延迟的提醒',
|
||||
'ids.array' => '提醒 id 列表格式错误',
|
||||
'promised_date.require' => '请选择患者承诺复诊日',
|
||||
'promised_date.dateFormat' => '承诺复诊日格式应为 YYYY-MM-DD',
|
||||
'reason.require' => '请选择关闭原因',
|
||||
'reason.in' => '关闭原因只能是 lost 或 other',
|
||||
'remark.require' => '请填写关闭说明',
|
||||
'remark.max' => '说明不能超过 200 字',
|
||||
];
|
||||
|
||||
public function sceneSnooze()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function sceneSnoozeAll()
|
||||
{
|
||||
return $this->only(['ids']);
|
||||
}
|
||||
|
||||
public function sceneContact()
|
||||
{
|
||||
return $this->only(['id', 'promised_date', 'remark']);
|
||||
}
|
||||
|
||||
public function sceneClose()
|
||||
{
|
||||
return $this->only(['id', 'reason', 'remark'])
|
||||
->append('remark', 'require');
|
||||
}
|
||||
|
||||
public function sceneLogs()
|
||||
{
|
||||
return $this->only(['due_id']);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use app\common\service\tcm\RevisitDueHook;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
use think\Response;
|
||||
@@ -303,6 +304,8 @@ class GancaoCallbackController extends BaseApiController
|
||||
private function updateOrderStatus(PrescriptionOrder $order, int $state, array $ext): void
|
||||
{
|
||||
$order->gancao_order_state = $state;
|
||||
// 甘草撤单/驳回把履约状态置 4(已取消):订单退出有效口径,需在落库成功后刷新复诊到期快照
|
||||
$revisitDueOrderCancelled = false;
|
||||
|
||||
switch ($state) {
|
||||
case 10:
|
||||
@@ -331,6 +334,7 @@ class GancaoCallbackController extends BaseApiController
|
||||
case 91:
|
||||
if ((int) $order->fulfillment_status !== 3) {
|
||||
$order->fulfillment_status = 4; // 已取消
|
||||
$revisitDueOrderCancelled = true;
|
||||
}
|
||||
$order->gancao_remark = '甘草主动撤单(已退费)';
|
||||
break;
|
||||
@@ -338,6 +342,7 @@ class GancaoCallbackController extends BaseApiController
|
||||
case 92:
|
||||
if ((int) $order->fulfillment_status !== 3) {
|
||||
$order->fulfillment_status = 4; // 已取消
|
||||
$revisitDueOrderCancelled = true;
|
||||
}
|
||||
$order->gancao_remark = '甘草驳回(无法制作并退费)';
|
||||
break;
|
||||
@@ -354,6 +359,10 @@ class GancaoCallbackController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
if ($savedOk && $revisitDueOrderCancelled) {
|
||||
RevisitDueHook::onOrderChanged((int) $order->diagnosis_id, (int) $order->id, 'gancao:' . $state);
|
||||
}
|
||||
|
||||
if ($savedOk && in_array((int) $order->fulfillment_status, [5, 6], true)) {
|
||||
ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
|
||||
'tracking_number' => (string) ($order->tracking_number ?? ''),
|
||||
|
||||
@@ -5,7 +5,9 @@ declare(strict_types=1);
|
||||
namespace app\command;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\RevisitRateCalculator;
|
||||
use app\adminapi\logic\stats\RevisitRateLogic;
|
||||
use app\common\service\tcm\RevisitDueHook;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
@@ -23,13 +25,19 @@ use think\facade\Log;
|
||||
* 推荐 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 口径完全一致):
|
||||
* 分配规则(依据上个自然月「二诊复诊接诊率」,取 RevisitRateLogic::overview(inherit_mode=slot) 的分档字段):
|
||||
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
|
||||
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
|
||||
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
|
||||
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
|
||||
* - 接诊率 < 50% 或上月无被指派数据(分母为 0 → null):不参与分配
|
||||
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
|
||||
*
|
||||
* 分档口径(config project.revisit_rate.auto_assign_tier_field,方案 D1-f 过渡期):
|
||||
* - visit2_rate_new_assign(默认,rate_version=v1):旧口径,分母仅新指派(is_inherit=0),与接诊率 v2 上线前完全一致;
|
||||
* - visit2_rate(rate_version=v2):新口径,分母 = 新指派 + 起算诊次 ≤ 2 的继承;产品确认后改配置即可切换,不发版。
|
||||
* 自动指派日志同时记录两套率(visit2_rate / visit2_rate_new_assign)与所用口径 rate_version;
|
||||
* --dry-run 额外输出两套口径的分档名单对照,便于切换前核对。
|
||||
*
|
||||
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
|
||||
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
|
||||
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
|
||||
@@ -61,11 +69,26 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
self::TIER_50_60 => '50%~60%',
|
||||
];
|
||||
|
||||
/** 可用的分档字段 => 口径版本(写入 auto_assign_log.rate_version) */
|
||||
private const TIER_FIELDS = [
|
||||
RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN => 'v1',
|
||||
'visit2_rate' => 'v2',
|
||||
];
|
||||
|
||||
private const RATE_VERSION_LABELS = [
|
||||
'v1' => '旧口径·分母仅新指派',
|
||||
'v2' => '新口径·每列独立分母',
|
||||
];
|
||||
|
||||
/** 本次执行的分档字段与口径版本(由 config 解析) */
|
||||
private string $tierField = RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
|
||||
private string $rateVersion = 'v1';
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('tcm:auto-assign-pending')
|
||||
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划与两套口径分档对照,不写库');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
@@ -75,19 +98,27 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
$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);
|
||||
$this->tierField = $this->resolveTierField();
|
||||
$this->rateVersion = self::TIER_FIELDS[$this->tierField];
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
|
||||
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s,分档口径=%s(%s)%s',
|
||||
date('Y-m-d H:i:s', $now),
|
||||
$batchNo,
|
||||
$statMonth,
|
||||
$this->rateVersion,
|
||||
self::RATE_VERSION_LABELS[$this->rateVersion],
|
||||
$dryRun ? '(演练模式,不写库)'
|
||||
: ''
|
||||
));
|
||||
|
||||
try {
|
||||
// 1. 上月二诊复诊接诊率 → 医助分档
|
||||
$tiers = $this->buildAssistantTiers($statMonth);
|
||||
// 1. 上月二诊复诊接诊率(slot 口径 overview,同时含旧口径字段)→ 医助分档
|
||||
$overview = RevisitRateLogic::overview([
|
||||
'month' => $statMonth,
|
||||
'inherit_mode' => RevisitRateCalculator::MODE_SLOT,
|
||||
]);
|
||||
$tiers = $this->buildAssistantTiers($overview, $this->tierField);
|
||||
$tierTotal = array_sum(array_map('count', $tiers));
|
||||
$output->writeln(sprintf(
|
||||
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
|
||||
@@ -95,6 +126,9 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
\count($tiers[self::TIER_60_70]),
|
||||
\count($tiers[self::TIER_50_60])
|
||||
));
|
||||
if ($dryRun) {
|
||||
$this->printTierComparison($overview, $output);
|
||||
}
|
||||
|
||||
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
|
||||
$remaining = $this->buildRemainingQuota($tiers, $runDate);
|
||||
@@ -163,14 +197,16 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
'assistant_id' => $item['assistant']['id'],
|
||||
'assistant_name' => $item['assistant']['name'],
|
||||
'tier' => $item['tier'],
|
||||
'visit2_rate' => $item['assistant']['rate'],
|
||||
'visit2_rate' => $item['assistant']['visit2_rate'],
|
||||
'visit2_rate_new_assign' => $item['assistant']['visit2_rate_new_assign'],
|
||||
'round_no' => $item['round'],
|
||||
'reason' => sprintf(
|
||||
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
|
||||
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%(%s),档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
|
||||
$item['assistant']['name'],
|
||||
$item['assistant']['id'],
|
||||
$statMonth,
|
||||
$item['assistant']['rate'],
|
||||
self::RATE_VERSION_LABELS[$this->rateVersion],
|
||||
self::TIER_LABELS[$item['tier']],
|
||||
self::TIER_DAILY_CAPS[$item['tier']],
|
||||
$item['round'],
|
||||
@@ -215,28 +251,95 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* 按上月二诊复诊接诊率把医助分档(复用 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}>}
|
||||
* 分档字段白名单:config project.revisit_rate.auto_assign_tier_field ∈ {visit2_rate_new_assign, visit2_rate},非法回退旧口径。
|
||||
*/
|
||||
private function buildAssistantTiers(string $statMonth): array
|
||||
private function resolveTierField(): string
|
||||
{
|
||||
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
|
||||
$raw = trim((string) (config('project.revisit_rate.auto_assign_tier_field') ?? ''));
|
||||
|
||||
/** @var list<array{id:int,name:string,rate:float}> $candidates */
|
||||
return isset(self::TIER_FIELDS[$raw]) ? $raw : RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 演练输出:两套口径(v1 旧 / v2 新)的分档名单对照,标出档位变动的医助。
|
||||
*
|
||||
* @param array<string, mixed> $overview RevisitRateLogic::overview(inherit_mode=slot) 返回体
|
||||
*/
|
||||
private function printTierComparison(array $overview, Output $output): void
|
||||
{
|
||||
$byField = [];
|
||||
foreach (self::TIER_FIELDS as $field => $version) {
|
||||
$byField[$version] = $this->buildAssistantTiers($overview, $field);
|
||||
}
|
||||
$output->writeln('[演练] 两套口径分档对照(v1=旧口径·分母仅新指派,v2=新口径·每列独立分母):');
|
||||
/** @var array<int, array{name:string,v1:string,v2:string,r1:?float,r2:?float}> $perAssistant */
|
||||
$perAssistant = [];
|
||||
foreach ($byField as $version => $tiers) {
|
||||
foreach ($tiers as $tier => $list) {
|
||||
$names = array_map(static fn (array $a): string => sprintf('%s(%.2f%%)', $a['name'], $a['rate']), $list);
|
||||
$output->writeln(sprintf(' [%s] %-8s %d 人:%s', $version, self::TIER_LABELS[$tier], \count($list), $names === [] ? '—' : implode('、', $names)));
|
||||
foreach ($list as $a) {
|
||||
$perAssistant[$a['id']] ??= ['name' => $a['name'], 'v1' => '', 'v2' => '', 'r1' => null, 'r2' => null];
|
||||
$perAssistant[$a['id']][$version] = $tier;
|
||||
$perAssistant[$a['id']][$version === 'v1' ? 'r1' : 'r2'] = $a['rate'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$changed = [];
|
||||
foreach ($perAssistant as $aid => $info) {
|
||||
if ($info['v1'] !== $info['v2']) {
|
||||
$changed[] = sprintf(
|
||||
'%s(ID:%d) v1=%s%s → v2=%s%s',
|
||||
$info['name'],
|
||||
$aid,
|
||||
$info['v1'] !== '' ? self::TIER_LABELS[$info['v1']] : '不参与',
|
||||
$info['r1'] !== null ? sprintf('(%.2f%%)', $info['r1']) : '',
|
||||
$info['v2'] !== '' ? self::TIER_LABELS[$info['v2']] : '不参与',
|
||||
$info['r2'] !== null ? sprintf('(%.2f%%)', $info['r2']) : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf(
|
||||
' 档位变动 %d 人:%s',
|
||||
\count($changed),
|
||||
$changed === [] ? '无(两套口径分档一致)' : implode(';', $changed)
|
||||
));
|
||||
$output->writeln(sprintf(' 本次实际采用:%s(%s)', $this->rateVersion, $this->tierField));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按上月二诊复诊接诊率把医助分档(取 overview 行的 $tierField 字段),并剔除已禁用/已删除账号。
|
||||
* 每位候选同时携带两套率(visit2_rate=v2、visit2_rate_new_assign=v1),供日志回溯。
|
||||
*
|
||||
* @param array<string, mixed> $overview RevisitRateLogic::overview(inherit_mode=slot) 返回体
|
||||
* @param string $tierField 分档字段(self::TIER_FIELDS 键)
|
||||
*
|
||||
* @return array{gt70: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>, 60_70: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>, 50_60: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>}
|
||||
*/
|
||||
private function buildAssistantTiers(array $overview, string $tierField): array
|
||||
{
|
||||
if (!isset(self::TIER_FIELDS[$tierField])) {
|
||||
$tierField = RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
|
||||
}
|
||||
|
||||
/** @var list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?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% 的医助不参与分配
|
||||
$rate = $row[$tierField] ?? null;
|
||||
// 上月无被指派数据(分母 0 → rate=null)或接诊率低于 50% 的医助不参与分配
|
||||
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
|
||||
continue;
|
||||
}
|
||||
$v2 = $row['visit2_rate'] ?? null;
|
||||
$v1 = $row[RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN] ?? null;
|
||||
$candidates[] = [
|
||||
'id' => $aid,
|
||||
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
|
||||
'rate' => (float) $rate,
|
||||
'visit2_rate' => $v2 === null ? null : (float) $v2,
|
||||
'visit2_rate_new_assign' => $v1 === null ? null : (float) $v1,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -520,6 +623,9 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
|
||||
Db::commit();
|
||||
|
||||
// 事务提交后刷新复诊到期快照(自动分配 = 换医助;钩子内部吞异常,不影响分配结果)
|
||||
RevisitDueHook::onAssistantChanged($diagnosisId, $toAssistantId, 'auto_assign');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
@@ -613,7 +719,7 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/visit2_rate_new_assign/round_no/reason
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
@@ -630,7 +736,10 @@ class AutoAssignPendingDiagnosis extends Command
|
||||
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
|
||||
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
|
||||
'tier' => (string) ($extra['tier'] ?? ''),
|
||||
// 两套口径的二诊率同时落库:visit2_rate=新口径 v2,visit2_rate_new_assign=旧口径 v1;rate_version 标识本批次分档所用口径
|
||||
'visit2_rate' => $extra['visit2_rate'] ?? null,
|
||||
'visit2_rate_new_assign' => $extra['visit2_rate_new_assign'] ?? null,
|
||||
'rate_version' => $this->rateVersion,
|
||||
'round_no' => (int) ($extra['round_no'] ?? 0),
|
||||
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
|
||||
'create_time' => $now,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\tcm\RevisitVisitSlotService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 继承指派「继承时诊次」快照回填 / 对账(tcm_diagnosis_assign_log.inherit_visit_slot / inherit_visit_slot_calc_time)
|
||||
*
|
||||
* 使用方法:
|
||||
* php think tcm:backfill-inherit-visit-slot --dry-run # 只计算、打印前 50 条拟写入,不写库
|
||||
* php think tcm:backfill-inherit-visit-slot # 回填 calc_time=0 的继承行
|
||||
* php think tcm:backfill-inherit-visit-slot --recalc # 全部继承行重算(含已回填)
|
||||
* php think tcm:backfill-inherit-visit-slot --verify # 对账:快照 vs 实时重算,输出漂移清单,不写库
|
||||
* php think tcm:backfill-inherit-visit-slot --month=2026-09 # 仅处理指派时间落在该月的继承行(可与以上组合)
|
||||
*
|
||||
* 口径(RevisitVisitSlotService::slotBefore,与 RevisitRateLogic / RevisitRateCalculator 完全一致):
|
||||
* k = 继承时刻之前(严格小于 create_time)的有效业务订单数(剔除履约 4/9/10、软删)+ 诊单偏移(钳 0~20)。
|
||||
* 统计口径永远实时重算,本列只用于指派记录面板展示与对账;事后退款/取消/改偏移会让快照与实时值漂移,属预期(--verify 列出)。
|
||||
*
|
||||
* 范围:is_inherit=1 AND to_assistant_id>0(to=0 的释放行 is_inherit 恒为 0)。按诊单分块 2000 批量取订单序列,不逐行查库。
|
||||
* 异常:整体 try/catch 并 return 0(Crontab::start 捕获异常会把 dev_crontab.status 置 ERROR 停跑)。
|
||||
*/
|
||||
class BackfillInheritVisitSlot extends Command
|
||||
{
|
||||
/** 每批处理的日志行数(按诊单分块取订单) */
|
||||
private const CHUNK_SIZE = 2000;
|
||||
|
||||
/** dry-run / verify 最多打印条数 */
|
||||
private const PRINT_LIMIT = 50;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('tcm:backfill-inherit-visit-slot')
|
||||
->setDescription('继承指派「继承时诊次」快照回填 / 对账(inherit_visit_slot)')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '只计算不写库,打印前 50 条拟写入')
|
||||
->addOption('recalc', null, Option::VALUE_NONE, '重算全部继承行(默认只回填 calc_time=0 的行)')
|
||||
->addOption('verify', null, Option::VALUE_NONE, '对账模式:输出快照 vs 实时漂移清单,不写库')
|
||||
->addOption('month', null, Option::VALUE_OPTIONAL, '仅处理指派时间落在该月(YYYY-MM)的继承行', '');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$now = time();
|
||||
|
||||
try {
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$recalc = (bool) $input->getOption('recalc');
|
||||
$verify = (bool) $input->getOption('verify');
|
||||
$month = trim((string) $input->getOption('month'));
|
||||
if ($month !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) !== 1) {
|
||||
$output->writeln('<error>无效 --month,格式应为 YYYY-MM</error>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$mode = $verify ? 'verify' : ($dryRun ? 'dry-run' : ($recalc ? 'recalc' : 'backfill'));
|
||||
$output->writeln(sprintf(
|
||||
'[%s] 继承时诊次快照 %s 开始%s',
|
||||
date('Y-m-d H:i:s', $now),
|
||||
$mode,
|
||||
$month !== '' ? ',月份=' . $month : ''
|
||||
));
|
||||
|
||||
$query = Db::name('tcm_diagnosis_assign_log')
|
||||
->where('is_inherit', 1)
|
||||
->where('to_assistant_id', '>', 0)
|
||||
->where('diagnosis_id', '>', 0);
|
||||
if ($month !== '') {
|
||||
$startTs = (int) strtotime($month . '-01 00:00:00');
|
||||
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
|
||||
$query->where('create_time', '>=', $startTs)->where('create_time', '<=', $endTs);
|
||||
}
|
||||
if (!$verify && !$recalc) {
|
||||
$query->where('inherit_visit_slot_calc_time', 0);
|
||||
}
|
||||
$rows = $query
|
||||
->field(['id', 'diagnosis_id', 'to_assistant_id', 'create_time', 'inherit_visit_slot', 'inherit_visit_slot_calc_time'])
|
||||
->order(['diagnosis_id' => 'asc', 'id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$total = \count($rows);
|
||||
$output->writeln(sprintf('待处理继承行 %d 条', $total));
|
||||
if ($total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$written = 0;
|
||||
$drift = 0;
|
||||
$printed = 0;
|
||||
foreach (array_chunk($rows, self::CHUNK_SIZE) as $chunk) {
|
||||
$diagIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['diagnosis_id'], $chunk)));
|
||||
$offsetMap = RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
|
||||
/** @var array<int, list<int>> $ctsByDiag */
|
||||
$ctsByDiag = [];
|
||||
foreach (RevisitVisitSlotService::fetchOrderSeqRows($diagIds, ['o.diagnosis_id', 'o.create_time']) as $o) {
|
||||
$ctsByDiag[(int) $o['diagnosis_id']][] = (int) $o['create_time'];
|
||||
}
|
||||
|
||||
foreach ($chunk as $r) {
|
||||
$processed++;
|
||||
$logId = (int) $r['id'];
|
||||
$did = (int) $r['diagnosis_id'];
|
||||
$t = (int) $r['create_time'];
|
||||
$realtime = min(255, RevisitVisitSlotService::slotBefore(
|
||||
$ctsByDiag[$did] ?? [],
|
||||
RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap),
|
||||
$t
|
||||
));
|
||||
$snapshot = (int) $r['inherit_visit_slot'];
|
||||
$calcTime = (int) $r['inherit_visit_slot_calc_time'];
|
||||
|
||||
if ($verify) {
|
||||
if ($calcTime > 0 && $snapshot === $realtime) {
|
||||
continue;
|
||||
}
|
||||
$drift++;
|
||||
if ($printed < self::PRINT_LIMIT) {
|
||||
$printed++;
|
||||
$output->writeln(sprintf(
|
||||
' 漂移 log_id=%d diagnosis_id=%d 指派时间=%s 快照=%d 实时=%d 原因=%s',
|
||||
$logId,
|
||||
$did,
|
||||
date('Y-m-d H:i:s', $t),
|
||||
$snapshot,
|
||||
$realtime,
|
||||
self::driftReason($calcTime, $snapshot, $realtime)
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
if ($printed < self::PRINT_LIMIT) {
|
||||
$printed++;
|
||||
$output->writeln(sprintf(
|
||||
' [演练] log_id=%d diagnosis_id=%d 指派时间=%s 当前快照=%d → 拟写入=%d',
|
||||
$logId,
|
||||
$did,
|
||||
date('Y-m-d H:i:s', $t),
|
||||
$snapshot,
|
||||
$realtime
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('tcm_diagnosis_assign_log')
|
||||
->where('id', $logId)
|
||||
->update([
|
||||
'inherit_visit_slot' => $realtime,
|
||||
'inherit_visit_slot_calc_time' => $now,
|
||||
]);
|
||||
$written++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($verify) {
|
||||
$output->writeln(sprintf(
|
||||
'对账完成:检查 %d 条,漂移 %d 条%s,耗时 %.2fs',
|
||||
$processed,
|
||||
$drift,
|
||||
$drift > self::PRINT_LIMIT ? sprintf('(仅打印前 %d 条)', self::PRINT_LIMIT) : '',
|
||||
microtime(true) - $startedAt
|
||||
));
|
||||
} elseif ($dryRun) {
|
||||
$output->writeln(sprintf('演练完成:应回填 %d 条(未落库),耗时 %.2fs', $processed, microtime(true) - $startedAt));
|
||||
} else {
|
||||
$output->writeln(sprintf('回填完成:处理 %d 条,写入 %d 条,耗时 %.2fs', $processed, $written, microtime(true) - $startedAt));
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('继承时诊次快照回填异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
$output->writeln('<error>执行异常: ' . $e->getMessage() . '</error>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 漂移原因(无历史快照时只能按方向推断) */
|
||||
private static function driftReason(int $calcTime, int $snapshot, int $realtime): string
|
||||
{
|
||||
if ($calcTime <= 0) {
|
||||
return '未回填(calc_time=0,加列前的历史继承行)';
|
||||
}
|
||||
if ($realtime > $snapshot) {
|
||||
return '实时值更大:继承前订单被恢复/新增有效订单,或诊单偏移上调';
|
||||
}
|
||||
|
||||
return '实时值更小:继承前订单被取消/退款/软删,或诊单偏移下调';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\adminapi\logic\tcm\RevisitDueLogic;
|
||||
use app\common\model\tcm\DiagnosisRevisitDueLog;
|
||||
use app\common\service\tcm\RevisitDueCalculator;
|
||||
use app\common\service\tcm\RevisitDueService;
|
||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 复诊到期提醒 - 企业微信推送(方案 §5.2,P2;project.revisit_reach.wecom_enabled=false 时空跑)
|
||||
*
|
||||
* 使用方法:
|
||||
* php think tcm:revisit-due-notify --mode=daily # 每日 09:00:每位有「到期未挂号 / 过号」行的医助一条汇总
|
||||
* php think tcm:revisit-due-notify --mode=tick # 每 30 分钟:escalation_level>=3 且到提醒时刻的行推本人(每行每日 ≤ wecom_daily_cap),
|
||||
* # snooze_count>=阈值 的行每日抄送一次配置名单主管(supervisor_admin_ids)
|
||||
* php think tcm:revisit-due-notify --mode=daily --dry-run # 只打印拟发送内容,不调企微、不写流水、不写回计数
|
||||
* php think tcm:revisit-due-notify --mode=daily --date=2026-10-20 --dry-run # 以指定基准日回放
|
||||
*
|
||||
* 选项:
|
||||
* --mode=daily|tick 默认 daily
|
||||
* --dry-run 只打印拟发送内容
|
||||
* --date=Y-m-d 基准日(默认今天);与今天不同时自动切换演练模式
|
||||
*
|
||||
* 约定:
|
||||
* - 推送只在本命令内发(接口不同步 curl);逐条 usleep(200ms);每条 try/catch;整体 try/catch 并 return 0
|
||||
* (Crontab::start 捕获到异常会把 dev_crontab.status 置 ERROR 停跑);
|
||||
* - 接收人取 admin.work_wechat_userid(同 DiagnosisTodoNotify),未绑定则跳过并写 event=8 流水(payload.skipped=unbound,不算失败);
|
||||
* - daily 去重:当日已有 event=8 且 payload.kind=daily 的医助不再发(流水判断,不改表);
|
||||
* - tick 本人:wecom_self_at / wecom_self_count_day 日切重置后计数,达到 wecom_daily_cap 不再推;
|
||||
* - tick 主管:wecom_supervisor_at 与今天同日则不再抄送;主管名单剔除医助本人并去重。
|
||||
*
|
||||
* 纯函数(isSameDay / selfSentToday / selfQuotaLeft / shouldCcSupervisor / normalizeSupervisorIds / overdueLabel /
|
||||
* buildDailyText / buildSelfText / buildSupervisorText / withinWorkHours / dayRange)不连库、不读 config,
|
||||
* 供 server/tests/RevisitDueNotifyTest.php 直接单测。
|
||||
*/
|
||||
class RevisitDueNotify extends Command
|
||||
{
|
||||
/** 流水 payload.kind:每日汇总 */
|
||||
public const KIND_DAILY = 'daily';
|
||||
/** 流水 payload.kind:tick 推本人 */
|
||||
public const KIND_SELF = 'self';
|
||||
/** 流水 payload.kind:tick 抄送主管 */
|
||||
public const KIND_SUPERVISOR = 'supervisor';
|
||||
|
||||
/** 文案落款 */
|
||||
public const SIGNATURE = '— 二中心跟踪系统';
|
||||
|
||||
/** 每日汇总最多列出的患者数(配置 wecom_daily_list_max 缺省值) */
|
||||
public const DAILY_LIST_MAX_DEFAULT = 5;
|
||||
|
||||
/** 抄送主管的延迟次数阈值(配置 wecom_supervisor_snooze_min 缺省值) */
|
||||
public const SUPERVISOR_SNOOZE_MIN_DEFAULT = 5;
|
||||
|
||||
/** 每条每日推送本人上限缺省值(配置 wecom_daily_cap 缺省值) */
|
||||
public const DAILY_CAP_DEFAULT = 2;
|
||||
|
||||
/** tick 单次最多处理行数 */
|
||||
private const TICK_ROW_LIMIT = 500;
|
||||
|
||||
/** 逐条发送间隔(微秒) */
|
||||
private const SEND_GAP_USEC = 200000;
|
||||
|
||||
/** 进入推送的展示状态 */
|
||||
private const PUSH_STATES = [RevisitDueCalculator::STATE_OVERDUE, RevisitDueCalculator::STATE_MISSED];
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('tcm:revisit-due-notify')
|
||||
->setDescription('复诊到期提醒企微推送:daily 每日汇总推医助本人 / tick 升级行推本人并抄送主管(wecom_enabled=false 时空跑)')
|
||||
->addOption('mode', null, Option::VALUE_OPTIONAL, '推送模式 daily|tick', 'daily')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '只打印拟发送内容,不调企微、不写流水')
|
||||
->addOption('date', null, Option::VALUE_OPTIONAL, '基准日 Y-m-d(默认今天)', '');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$now = time();
|
||||
|
||||
try {
|
||||
$mode = strtolower(trim((string) $input->getOption('mode')));
|
||||
if ($mode === '') {
|
||||
$mode = 'daily';
|
||||
}
|
||||
if (!in_array($mode, ['daily', 'tick'], true)) {
|
||||
$output->writeln('<error>无效 --mode,仅支持 daily|tick</error>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$today = trim((string) $input->getOption('date'));
|
||||
if ($today !== '' && !$this->isValidDate($today)) {
|
||||
$output->writeln('<error>无效 --date,格式应为 Y-m-d</error>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
if ($today === '') {
|
||||
$today = date('Y-m-d', $now);
|
||||
} elseif ($today !== date('Y-m-d', $now) && !$dryRun) {
|
||||
$output->writeln('<comment>--date 与今天不同,已自动切换为演练模式(--dry-run),不发送、不写库</comment>');
|
||||
$dryRun = true;
|
||||
}
|
||||
|
||||
$cfg = RevisitDueService::config();
|
||||
$output->writeln(sprintf(
|
||||
'[%s] 复诊到期企微推送 mode=%s date=%s%s',
|
||||
date('Y-m-d H:i:s', $now),
|
||||
$mode,
|
||||
$today,
|
||||
$dryRun ? ' (演练模式,不发送)' : ''
|
||||
));
|
||||
|
||||
if (empty($cfg['wecom_enabled'])) {
|
||||
$output->writeln('企微推送未启用(project.revisit_reach.wecom_enabled=false),本次不处理。');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$stats = $mode === 'daily'
|
||||
? $this->runDaily($output, $cfg, $today, $now, $dryRun)
|
||||
: $this->runTick($output, $cfg, $today, $now, $dryRun);
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'完成。候选行 %d,发送 %d,失败 %d,未绑定跳过 %d,去重跳过 %d,抄送主管 %d,耗时 %ss',
|
||||
$stats['rows'],
|
||||
$stats['sent'],
|
||||
$stats['failed'],
|
||||
$stats['unbound'],
|
||||
$stats['deduped'],
|
||||
$stats['supervisor'],
|
||||
round(microtime(true) - $startedAt, 2)
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('tcm:revisit-due-notify 执行异常: ' . $e->getMessage());
|
||||
$output->writeln('<error>执行异常:' . $e->getMessage() . '</error>');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- daily
|
||||
|
||||
/**
|
||||
* 每日汇总:按医助分组,一人一条。
|
||||
*
|
||||
* @param array<string, mixed> $cfg
|
||||
*
|
||||
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
|
||||
*/
|
||||
private function runDaily(Output $output, array $cfg, string $today, int $now, bool $dryRun): array
|
||||
{
|
||||
$stats = $this->emptyStats();
|
||||
$listMax = max(1, (int) ($cfg['wecom_daily_list_max'] ?? self::DAILY_LIST_MAX_DEFAULT));
|
||||
|
||||
$rows = $this->dueQuery($today)
|
||||
->order('assistant_id ASC, escalation_level DESC, due_date ASC, id ASC')
|
||||
->select()
|
||||
->toArray();
|
||||
$stats['rows'] = \count($rows);
|
||||
if ($rows === []) {
|
||||
$output->writeln('无到期未挂号 / 过号行。');
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
// 按医助分组,只保留读取侧推导为 超期 / 过号 的行
|
||||
$groups = [];
|
||||
foreach ($rows as $row) {
|
||||
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
|
||||
if (!in_array($st['state'], self::PUSH_STATES, true)) {
|
||||
continue;
|
||||
}
|
||||
$row['_st'] = $st;
|
||||
$groups[(int) $row['assistant_id']][] = $row;
|
||||
}
|
||||
if ($groups === []) {
|
||||
$output->writeln('候选行经状态推导后均不需提醒。');
|
||||
|
||||
return $stats;
|
||||
}
|
||||
$assistantIds = array_keys($groups);
|
||||
|
||||
// 当日已发过汇总的医助(流水判断:event=8 且 payload.kind=daily)
|
||||
$sentToday = $this->dailySentAssistantIds($assistantIds, $today);
|
||||
$admins = $this->fetchAdmins($assistantIds);
|
||||
|
||||
foreach ($groups as $aid => $items) {
|
||||
try {
|
||||
if (isset($sentToday[$aid])) {
|
||||
$stats['deduped']++;
|
||||
$output->writeln(sprintf(' 医助 %d 今日已发过汇总,跳过(%d 行)', $aid, \count($items)));
|
||||
continue;
|
||||
}
|
||||
$text = self::buildDailyText(array_map(static fn (array $r): array => [
|
||||
'patient_name' => (string) ($r['patient_name'] ?? ''),
|
||||
'state' => (string) $r['_st']['state'],
|
||||
'overdue_days' => $r['_st']['overdue_days'],
|
||||
], $items), $listMax);
|
||||
|
||||
$admin = $admins[$aid] ?? null;
|
||||
$wxId = trim((string) ($admin['work_wechat_userid'] ?? ''));
|
||||
$name = (string) ($admin['name'] ?? ('#' . $aid));
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf(' [拟发送 daily] 医助 %s(%d) → %s | %s', $name, $aid, $wxId !== '' ? $wxId : '(未绑定)', $text));
|
||||
$wxId !== '' ? $stats['sent']++ : $stats['unbound']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$payloadBase = ['kind' => self::KIND_DAILY, 'to' => $wxId, 'n' => \count($items)];
|
||||
if ($wxId === '') {
|
||||
$stats['unbound']++;
|
||||
$output->writeln(sprintf(' 医助 %s(%d) 未绑定企微 userid,跳过(%d 行)', $name, $aid, \count($items)));
|
||||
$this->logRows($items, $today, $now, $payloadBase + ['ok' => false, 'skipped' => 'unbound']);
|
||||
continue;
|
||||
}
|
||||
|
||||
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
|
||||
$ok = (bool) ($res['ok'] ?? false);
|
||||
$payload = $payloadBase + ['ok' => $ok];
|
||||
if (!$ok) {
|
||||
$payload['errcode'] = (int) ($res['errcode'] ?? -1);
|
||||
$payload['msg'] = mb_substr((string) ($res['message'] ?? ''), 0, 60);
|
||||
$stats['failed']++;
|
||||
$output->writeln(sprintf(' <error>医助 %s(%d) 发送失败:%s</error>', $name, $aid, (string) ($res['message'] ?? '')));
|
||||
} else {
|
||||
$stats['sent']++;
|
||||
$output->writeln(sprintf(' 医助 %s(%d) 已发送汇总(%d 行)', $name, $aid, \count($items)));
|
||||
}
|
||||
$this->logRows($items, $today, $now, $payload);
|
||||
usleep(self::SEND_GAP_USEC);
|
||||
} catch (\Throwable $e) {
|
||||
$stats['failed']++;
|
||||
Log::error('tcm:revisit-due-notify daily 单医助处理异常 assistant_id=' . $aid . ' msg=' . $e->getMessage());
|
||||
$output->writeln(sprintf(' <error>医助 %d 处理异常:%s</error>', $aid, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tick
|
||||
|
||||
/**
|
||||
* tick:升级行推本人(每行每日 ≤ cap),达阈值的行每日抄送主管一次。
|
||||
*
|
||||
* @param array<string, mixed> $cfg
|
||||
*
|
||||
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
|
||||
*/
|
||||
private function runTick(Output $output, array $cfg, string $today, int $now, bool $dryRun): array
|
||||
{
|
||||
$stats = $this->emptyStats();
|
||||
$cap = max(0, (int) ($cfg['wecom_daily_cap'] ?? self::DAILY_CAP_DEFAULT));
|
||||
$minSnooze = max(1, (int) ($cfg['wecom_supervisor_snooze_min'] ?? self::SUPERVISOR_SNOOZE_MIN_DEFAULT));
|
||||
|
||||
if (!self::withinWorkHours($now, $cfg['work_hours'] ?? null)) {
|
||||
$output->writeln('当前不在工作时段(work_hours),tick 不推送。');
|
||||
if (!$dryRun) {
|
||||
return $stats;
|
||||
}
|
||||
$output->writeln('<comment>演练模式继续计算拟发送内容</comment>');
|
||||
}
|
||||
|
||||
$rows = $this->dueQuery($today)
|
||||
->where('escalation_level', '>=', 3)
|
||||
->where('next_remind_at', '<=', $now)
|
||||
->order('escalation_level DESC, due_date ASC, id ASC')
|
||||
->limit(self::TICK_ROW_LIMIT)
|
||||
->select()
|
||||
->toArray();
|
||||
$stats['rows'] = \count($rows);
|
||||
if ($rows === []) {
|
||||
$output->writeln('无需推送的升级行。');
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$assistantIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['assistant_id'], $rows)));
|
||||
$supervisorPool = self::normalizeSupervisorIds($cfg['supervisor_admin_ids'] ?? []);
|
||||
$admins = $this->fetchAdmins(array_values(array_unique(array_merge($assistantIds, $supervisorPool))));
|
||||
$warnedNoSupervisor = false;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) $row['id'];
|
||||
$aid = (int) $row['assistant_id'];
|
||||
try {
|
||||
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
|
||||
if (!in_array($st['state'], self::PUSH_STATES, true)) {
|
||||
continue;
|
||||
}
|
||||
$admin = $admins[$aid] ?? null;
|
||||
$wxId = trim((string) ($admin['work_wechat_userid'] ?? ''));
|
||||
$name = (string) ($admin['name'] ?? ('#' . $aid));
|
||||
$patient = trim((string) ($row['patient_name'] ?? ''));
|
||||
$logBase = [
|
||||
'snooze_count_after' => (int) ($row['snooze_count'] ?? 0),
|
||||
'level_after' => (int) ($row['escalation_level'] ?? 0),
|
||||
'overdue_days' => $this->overdueDaysForLog($row, $today),
|
||||
'operator_id' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
|
||||
// ---- 推本人(每行每日 ≤ cap) ----
|
||||
$sentCnt = self::selfSentToday($row, $now);
|
||||
$left = self::selfQuotaLeft($row, $now, $cap);
|
||||
if ($left <= 0) {
|
||||
$stats['deduped']++;
|
||||
} else {
|
||||
$text = self::buildSelfText($row, $st);
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf(' [拟发送 self] due#%d 医助 %s(%d) → %s | %s', $id, $name, $aid, $wxId !== '' ? $wxId : '(未绑定)', $text));
|
||||
$wxId !== '' ? $stats['sent']++ : $stats['unbound']++;
|
||||
} else {
|
||||
$payload = ['kind' => self::KIND_SELF, 'to' => $wxId, 'seq' => $sentCnt + 1, 'cap' => $cap];
|
||||
if ($wxId === '') {
|
||||
$stats['unbound']++;
|
||||
$payload += ['ok' => false, 'skipped' => 'unbound'];
|
||||
$output->writeln(sprintf(' due#%d 医助 %s(%d) 未绑定企微 userid,跳过', $id, $name, $aid));
|
||||
} else {
|
||||
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
|
||||
$ok = (bool) ($res['ok'] ?? false);
|
||||
$payload['ok'] = $ok;
|
||||
if ($ok) {
|
||||
$stats['sent']++;
|
||||
$output->writeln(sprintf(' due#%d 医助 %s(%d) 患者 %s 已推送本人(今日第 %d 次)', $id, $name, $aid, $patient, $sentCnt + 1));
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
$payload['errcode'] = (int) ($res['errcode'] ?? -1);
|
||||
$payload['msg'] = mb_substr((string) ($res['message'] ?? ''), 0, 60);
|
||||
$output->writeln(sprintf(' <error>due#%d 医助 %s(%d) 推送失败:%s</error>', $id, $name, $aid, (string) ($res['message'] ?? '')));
|
||||
}
|
||||
usleep(self::SEND_GAP_USEC);
|
||||
}
|
||||
// 未绑定 / 失败同样计入当日次数,避免每 30 分钟重复刷流水
|
||||
Db::name(RevisitDueService::TABLE)->where('id', $id)->update([
|
||||
'wecom_self_at' => $now,
|
||||
'wecom_self_count_day' => min(255, $sentCnt + 1),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
RevisitDueService::writeLog($id, (int) $row['diagnosis_id'], $aid, (int) $row['source_order_id'], DiagnosisRevisitDueLog::EVENT_WECOM_PUSH, $logBase + ['payload' => $payload]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 抄送主管(每行每日一次) ----
|
||||
if (!self::shouldCcSupervisor($row, $now, $minSnooze)) {
|
||||
continue;
|
||||
}
|
||||
$supervisors = self::normalizeSupervisorIds($supervisorPool, $aid);
|
||||
if ($supervisors === []) {
|
||||
if (!$warnedNoSupervisor) {
|
||||
$output->writeln('<comment> 未配置主管名单(project.revisit_reach.supervisor_admin_ids)或仅含医助本人,跳过抄送</comment>');
|
||||
$warnedNoSupervisor = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$supText = self::buildSupervisorText($name, $row, $st);
|
||||
$toList = [];
|
||||
$okCnt = 0;
|
||||
$failCnt = 0;
|
||||
$firstErr = null;
|
||||
foreach ($supervisors as $sid) {
|
||||
$sWx = trim((string) ($admins[$sid]['work_wechat_userid'] ?? ''));
|
||||
if ($sWx === '') {
|
||||
$output->writeln(sprintf(' 主管 %d 未绑定企微 userid,跳过', $sid));
|
||||
continue;
|
||||
}
|
||||
$toList[] = $sWx;
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf(' [拟发送 supervisor] due#%d → 主管 %d(%s) | %s', $id, $sid, $sWx, $supText));
|
||||
$okCnt++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$res = WechatWorkAppMessageService::sendTextToUser($sWx, $supText);
|
||||
if (!empty($res['ok'])) {
|
||||
$okCnt++;
|
||||
} else {
|
||||
$failCnt++;
|
||||
$firstErr ??= (int) ($res['errcode'] ?? -1);
|
||||
$output->writeln(sprintf(' <error>due#%d 抄送主管 %d 失败:%s</error>', $id, $sid, (string) ($res['message'] ?? '')));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failCnt++;
|
||||
Log::error('tcm:revisit-due-notify 抄送主管异常 due_id=' . $id . ' supervisor=' . $sid . ' msg=' . $e->getMessage());
|
||||
}
|
||||
usleep(self::SEND_GAP_USEC);
|
||||
}
|
||||
if ($dryRun) {
|
||||
$stats['supervisor'] += $okCnt > 0 ? 1 : 0;
|
||||
continue;
|
||||
}
|
||||
$payload = ['kind' => self::KIND_SUPERVISOR, 'to' => implode(',', $toList), 'ok' => $failCnt === 0 && $okCnt > 0, 'sent' => $okCnt, 'fail' => $failCnt];
|
||||
if ($toList === []) {
|
||||
$payload['skipped'] = 'unbound';
|
||||
}
|
||||
if ($firstErr !== null) {
|
||||
$payload['errcode'] = $firstErr;
|
||||
}
|
||||
// 不论成败,当日只抄送一次
|
||||
Db::name(RevisitDueService::TABLE)->where('id', $id)->update(['wecom_supervisor_at' => $now, 'update_time' => $now]);
|
||||
RevisitDueService::writeLog($id, (int) $row['diagnosis_id'], $aid, (int) $row['source_order_id'], DiagnosisRevisitDueLog::EVENT_WECOM_PUSH, $logBase + ['payload' => $payload]);
|
||||
if ($okCnt > 0) {
|
||||
$stats['supervisor']++;
|
||||
$output->writeln(sprintf(' due#%d 已抄送主管 %d 人(医助 %s,患者 %s,延迟 %d 次)', $id, $okCnt, $name, $patient, (int) $row['snooze_count']));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$stats['failed']++;
|
||||
Log::error('tcm:revisit-due-notify tick 单行处理异常 due_id=' . $id . ' msg=' . $e->getMessage());
|
||||
$output->writeln(sprintf(' <error>due#%d 处理异常:%s</error>', $id, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 纯函数
|
||||
|
||||
/**
|
||||
* 两个时间戳是否同一自然日(ts<=0 视为从未发生 → false)。
|
||||
*/
|
||||
public static function isSameDay(int $ts, int $now): bool
|
||||
{
|
||||
if ($ts <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return date('Y-m-d', $ts) === date('Y-m-d', $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当日已推送本人次数:wecom_self_at 与 now 同日取 wecom_self_count_day,否则日切归 0。
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function selfSentToday(array $row, int $now): int
|
||||
{
|
||||
$at = (int) ($row['wecom_self_at'] ?? 0);
|
||||
if (!self::isSameDay($at, $now)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, (int) ($row['wecom_self_count_day'] ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当日剩余可推本人次数(cap<=0 表示不推)。
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function selfQuotaLeft(array $row, int $now, int $cap): int
|
||||
{
|
||||
if ($cap <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, $cap - self::selfSentToday($row, $now));
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要抄送主管:snooze_count >= minSnooze 且今天尚未抄送(wecom_supervisor_at 日切)。
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function shouldCcSupervisor(array $row, int $now, int $minSnooze): bool
|
||||
{
|
||||
if ((int) ($row['snooze_count'] ?? 0) < max(1, $minSnooze)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !self::isSameDay((int) ($row['wecom_supervisor_at'] ?? 0), $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主管名单归一:容忍逗号字符串 / 数组,转正整数、去重、剔除医助本人。
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function normalizeSupervisorIds(mixed $ids, int $excludeAdminId = 0): array
|
||||
{
|
||||
if (is_string($ids)) {
|
||||
$ids = preg_split('/[\s,]+/', trim($ids)) ?: [];
|
||||
}
|
||||
if (!is_array($ids)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($ids as $v) {
|
||||
$id = (int) $v;
|
||||
if ($id > 0 && $id !== $excludeAdminId && !in_array($id, $out, true)) {
|
||||
$out[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 超期标签:过号 → 「过号」(有超期天数再接「·超期N天」);超期 N 天 → 「超期N天」;0 → 「今日应挂号」。
|
||||
*
|
||||
* @param array{state?:string, overdue_days?:int|null} $st deriveState 结果
|
||||
*/
|
||||
public static function overdueLabel(array $st): string
|
||||
{
|
||||
$overdue = $st['overdue_days'] ?? null;
|
||||
$overdue = $overdue === null ? null : (int) $overdue;
|
||||
if (($st['state'] ?? '') === RevisitDueCalculator::STATE_MISSED) {
|
||||
return '过号' . ($overdue !== null && $overdue > 0 ? '·超期' . $overdue . '天' : '');
|
||||
}
|
||||
if ($overdue === null) {
|
||||
return '到期';
|
||||
}
|
||||
|
||||
return $overdue > 0 ? '超期' . $overdue . '天' : '今日应挂号';
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日汇总文案:按超期天数降序列出前 listMax 人。
|
||||
*
|
||||
* @param list<array{patient_name:string, state:string, overdue_days:int|null}> $items
|
||||
*/
|
||||
public static function buildDailyText(array $items, int $listMax = self::DAILY_LIST_MAX_DEFAULT): string
|
||||
{
|
||||
$n = \count($items);
|
||||
if ($n === 0) {
|
||||
return '';
|
||||
}
|
||||
$listMax = max(1, $listMax);
|
||||
usort($items, static function (array $a, array $b): int {
|
||||
return (int) ($b['overdue_days'] ?? 0) <=> (int) ($a['overdue_days'] ?? 0);
|
||||
});
|
||||
$maxOverdue = 0;
|
||||
foreach ($items as $it) {
|
||||
$maxOverdue = max($maxOverdue, (int) ($it['overdue_days'] ?? 0));
|
||||
}
|
||||
$parts = [];
|
||||
foreach (array_slice($items, 0, $listMax) as $it) {
|
||||
$name = trim((string) ($it['patient_name'] ?? ''));
|
||||
$parts[] = ($name !== '' ? $name : '患者') . '(' . self::overdueLabel($it) . ')';
|
||||
}
|
||||
$text = '【复诊到期提醒】您有 ' . $n . ' 位患者到期未挂号';
|
||||
if ($maxOverdue > 0) {
|
||||
$text .= ',最久超期 ' . $maxOverdue . ' 天';
|
||||
}
|
||||
$text .= ':' . implode('、', $parts);
|
||||
if ($n > $listMax) {
|
||||
$text .= '…等 ' . $n . ' 人';
|
||||
}
|
||||
|
||||
return $text . ',请尽快联系患者挂号。' . self::SIGNATURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* tick 推本人文案。
|
||||
*
|
||||
* @param array<string, mixed> $row 快照行
|
||||
* @param array{state?:string, overdue_days?:int|null} $st deriveState 结果
|
||||
*/
|
||||
public static function buildSelfText(array $row, array $st): string
|
||||
{
|
||||
$name = trim((string) ($row['patient_name'] ?? ''));
|
||||
$name = $name !== '' ? $name : '患者';
|
||||
$slot = (int) ($row['current_slot'] ?? 0);
|
||||
$due = self::dateStr($row['due_date'] ?? null);
|
||||
$snooze = (int) ($row['snooze_count'] ?? 0);
|
||||
|
||||
$text = '【复诊到期提醒】患者 ' . $name;
|
||||
$text .= '(' . ($slot > 0 ? '第' . $slot . '诊后' : '待复诊') . ($due !== '' ? ',预计复诊 ' . substr($due, 5) : '') . ')';
|
||||
$text .= self::overdueLabel($st) . '未挂号,您已延迟 ' . $snooze . ' 次,请尽快联系患者挂号。';
|
||||
|
||||
return $text . self::SIGNATURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* tick 抄送主管文案:含医助姓名、患者、超期天数、已延迟次数。
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
* @param array{state?:string, overdue_days?:int|null} $st
|
||||
*/
|
||||
public static function buildSupervisorText(string $assistantName, array $row, array $st): string
|
||||
{
|
||||
$assistantName = trim($assistantName);
|
||||
$assistantName = $assistantName !== '' ? $assistantName : ('#' . (int) ($row['assistant_id'] ?? 0));
|
||||
$name = trim((string) ($row['patient_name'] ?? ''));
|
||||
$name = $name !== '' ? $name : '患者';
|
||||
$slot = (int) ($row['current_slot'] ?? 0);
|
||||
$due = self::dateStr($row['due_date'] ?? null);
|
||||
$snooze = (int) ($row['snooze_count'] ?? 0);
|
||||
$contact = (int) ($row['contact_count'] ?? 0);
|
||||
|
||||
$text = '【复诊到期升级】医助 ' . $assistantName . ' 的患者 ' . $name . ' ' . self::overdueLabel($st) . '未挂号';
|
||||
$text .= '(' . ($slot > 0 ? '第' . $slot . '诊后' : '待复诊') . ($due !== '' ? ',预计复诊 ' . substr($due, 5) : '') . ')';
|
||||
$text .= ',已延迟 ' . $snooze . ' 次' . ($contact > 0 ? '、已联系 ' . $contact . ' 次' : '') . ',请关注。';
|
||||
|
||||
return $text . self::SIGNATURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* now 是否落在工作时段 [start, end)(复用 RevisitDueLogic::parseWorkHours 的归一规则,非法配置回落 09:00~20:00)。
|
||||
*/
|
||||
public static function withinWorkHours(int $now, mixed $workHours): bool
|
||||
{
|
||||
[$startMin, $endMin] = RevisitDueLogic::parseWorkHours($workHours);
|
||||
$minuteOfDay = (int) date('G', $now) * 60 + (int) date('i', $now);
|
||||
|
||||
return $minuteOfDay >= $startMin && $minuteOfDay < $endMin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Y-m-d' → 当日 [00:00:00, 23:59:59] 时间戳;非法日期按今天。
|
||||
*
|
||||
* @return array{0:int, 1:int}
|
||||
*/
|
||||
public static function dayRange(string $today): array
|
||||
{
|
||||
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($today), 0, 10));
|
||||
if ($d === false) {
|
||||
$d = new \DateTimeImmutable('today');
|
||||
}
|
||||
$start = $d->getTimestamp();
|
||||
|
||||
return [$start, $start + 86399];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 内部:取数与写入
|
||||
|
||||
/**
|
||||
* 到期未挂号 / 过号的进行中行(口径同 RevisitDueLogic::applyDueWhere,基准日用 --date)。
|
||||
*
|
||||
* @return \think\db\Query
|
||||
*/
|
||||
private function dueQuery(string $today)
|
||||
{
|
||||
return Db::name(RevisitDueService::TABLE)
|
||||
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
|
||||
->where('assistant_id', '>', 0)
|
||||
->whereRaw('(appointment_state = ' . RevisitDueCalculator::APPT_MISSED . ' OR (due_date IS NOT NULL AND due_date <= :d1))', ['d1' => $today])
|
||||
->whereRaw('(promised_date IS NULL OR promised_date < :d2)', ['d2' => $today]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当日已发过 daily 汇总的医助 id 集合(event=8 且 payload.kind=daily)。
|
||||
*
|
||||
* @param list<int> $assistantIds
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private function dailySentAssistantIds(array $assistantIds, string $today): array
|
||||
{
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
[$start, $end] = self::dayRange($today);
|
||||
$ids = Db::name(RevisitDueService::LOG_TABLE)
|
||||
->where('event', DiagnosisRevisitDueLog::EVENT_WECOM_PUSH)
|
||||
->whereIn('assistant_id', $assistantIds)
|
||||
->whereBetween('create_time', [$start, $end])
|
||||
->whereLike('payload', '%"kind":"' . self::KIND_DAILY . '"%')
|
||||
->distinct(true)
|
||||
->column('assistant_id');
|
||||
|
||||
$out = [];
|
||||
foreach ($ids as $id) {
|
||||
$out[(int) $id] = true;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量取账号姓名与企微 userid。
|
||||
*
|
||||
* @param list<int> $adminIds
|
||||
*
|
||||
* @return array<int, array{name:string, work_wechat_userid:string}>
|
||||
*/
|
||||
private function fetchAdmins(array $adminIds): array
|
||||
{
|
||||
$adminIds = array_values(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0));
|
||||
if ($adminIds === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (array_chunk($adminIds, 500) as $chunk) {
|
||||
// 直接查表:Admin 模型的 append(role_id/dept_id 访问器)会逐行再查库
|
||||
$rows = Db::name('admin')
|
||||
->whereIn('id', $chunk)
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'name', 'work_wechat_userid'])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $r) {
|
||||
$out[(int) $r['id']] = [
|
||||
'name' => (string) ($r['name'] ?? ''),
|
||||
'work_wechat_userid' => (string) ($r['work_wechat_userid'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* daily:为汇总覆盖的每一行写一条 event=8 流水(主管视图逐行可见)。
|
||||
*
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function logRows(array $items, string $today, int $now, array $payload): void
|
||||
{
|
||||
foreach ($items as $row) {
|
||||
RevisitDueService::writeLog(
|
||||
(int) $row['id'],
|
||||
(int) $row['diagnosis_id'],
|
||||
(int) $row['assistant_id'],
|
||||
(int) $row['source_order_id'],
|
||||
DiagnosisRevisitDueLog::EVENT_WECOM_PUSH,
|
||||
[
|
||||
'snooze_count_after' => (int) ($row['snooze_count'] ?? 0),
|
||||
'level_after' => (int) ($row['escalation_level'] ?? 0),
|
||||
'overdue_days' => $this->overdueDaysForLog($row, $today),
|
||||
'operator_id' => 0,
|
||||
'create_time' => $now,
|
||||
'payload' => $payload,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流水 overdue_days:today − due_date(负=未到期),无 due_date 记 0。
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function overdueDaysForLog(array $row, string $today): int
|
||||
{
|
||||
$due = self::dateStr($row['due_date'] ?? null);
|
||||
|
||||
return $due !== '' ? RevisitDueCalculator::dayDiff($due, $today) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
|
||||
*/
|
||||
private function emptyStats(): array
|
||||
{
|
||||
return ['rows' => 0, 'sent' => 0, 'failed' => 0, 'unbound' => 0, 'deduped' => 0, 'supervisor' => 0];
|
||||
}
|
||||
|
||||
private function isValidDate(string $date): bool
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||
return false;
|
||||
}
|
||||
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
|
||||
return $d !== false && $d->format('Y-m-d') === $date;
|
||||
}
|
||||
|
||||
/** 日期列 → 'Y-m-d' 或 ''(null / 空 / 0000-00-00 均视为空) */
|
||||
private static function dateStr(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$s = substr(trim((string) $value), 0, 10);
|
||||
|
||||
return ($s === '' || $s === '0000-00-00') ? '' : $s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\service\tcm\RevisitDueCalculator;
|
||||
use app\common\service\tcm\RevisitDueService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 复诊到期快照重算(tcm_diagnosis_revisit_due)
|
||||
*
|
||||
* 使用方法:
|
||||
* php think tcm:revisit-due-scan --mode=full # 每日全量(zyt_dev_crontab:30 8 * * *)
|
||||
* php think tcm:revisit-due-scan --mode=incremental # 增量(每 30 分钟):近 N 分钟有订单/挂号/指派/诊单变更的诊单 + closed_state=2 行
|
||||
* php think tcm:revisit-due-scan --dry-run --diagnosis-id=123 # 只计算不写库,打印拟写入行(抽样核对页面)
|
||||
* php think tcm:revisit-due-scan --mode=full --date=2026-10-20 --dry-run # 以指定基准日回放状态
|
||||
*
|
||||
* 选项:
|
||||
* --mode=full|incremental 默认 full
|
||||
* --dry-run 只计算并打印前 20 条结果,不写库、不写流水、不更新防重入标记
|
||||
* --diagnosis-id=1,2,3 仅处理指定诊单(逗号分隔),跳过候选扫描与防重入
|
||||
* --date=Y-m-d 基准日(默认今天),影响「已约是否仍在未来」与打印的推导状态
|
||||
* --since-minutes=120 增量窗口(分钟),仅 incremental 有效
|
||||
*
|
||||
* 防重入:同一 mode 上次开始时间距今 < 5 分钟则跳过(Cache 标记;钩子也会刷 computed_at,故不用 MAX(computed_at) 判断)。
|
||||
* 异常:整体 try/catch 并 return 0、按块隔离(Crontab::start 捕获到异常会把 dev_crontab.status 置 ERROR 停跑)。
|
||||
*/
|
||||
class RevisitDueScan extends Command
|
||||
{
|
||||
/** 每批刷新的诊单数 */
|
||||
private const CHUNK_SIZE = 500;
|
||||
|
||||
/** 防重入窗口(秒) */
|
||||
private const REENTRY_GUARD_SECONDS = 300;
|
||||
|
||||
/** dry-run 最多打印条数 */
|
||||
private const DRY_RUN_PRINT_LIMIT = 20;
|
||||
|
||||
/** 防重入缓存键前缀 */
|
||||
private const CACHE_KEY_PREFIX = 'tcm_revisit_due_scan_last_start:';
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('tcm:revisit-due-scan')
|
||||
->setDescription('复诊到期快照重算:full 全量 / incremental 增量(订单、挂号、指派、诊单变更 + 已挂号行重评)')
|
||||
->addOption('mode', null, Option::VALUE_OPTIONAL, '扫描模式 full|incremental', 'full')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, '只计算不写库,打印前 20 条拟写入行')
|
||||
->addOption('diagnosis-id', null, Option::VALUE_OPTIONAL, '仅处理指定诊单 id(逗号分隔)', '')
|
||||
->addOption('date', null, Option::VALUE_OPTIONAL, '基准日 Y-m-d(默认今天)', '')
|
||||
->addOption('since-minutes', null, Option::VALUE_OPTIONAL, '增量窗口(分钟)', 120);
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$now = time();
|
||||
|
||||
try {
|
||||
$mode = strtolower(trim((string) $input->getOption('mode')));
|
||||
if ($mode === '') {
|
||||
$mode = 'full';
|
||||
}
|
||||
if (!in_array($mode, ['full', 'incremental'], true)) {
|
||||
$output->writeln('<error>无效 --mode,仅支持 full|incremental</error>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$today = trim((string) $input->getOption('date'));
|
||||
if ($today !== '' && !$this->isValidDate($today)) {
|
||||
$output->writeln('<error>无效 --date,格式应为 Y-m-d</error>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
if ($today === '') {
|
||||
$today = date('Y-m-d', $now);
|
||||
} elseif ($today !== date('Y-m-d', $now) && !$dryRun) {
|
||||
// --date 只用于基准日回放:以假基准日真正写库会把线上快照(已约是否过期、流水超期天数)算错
|
||||
$output->writeln('<comment>--date 与今天不同,已自动切换为演练模式(--dry-run),不写库</comment>');
|
||||
$dryRun = true;
|
||||
}
|
||||
$sinceMinutes = (int) $input->getOption('since-minutes');
|
||||
if ($sinceMinutes <= 0) {
|
||||
$sinceMinutes = 120;
|
||||
}
|
||||
$explicitIds = $this->parseIdList((string) $input->getOption('diagnosis-id'));
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'[%s] 复诊到期快照重算 mode=%s date=%s%s%s',
|
||||
date('Y-m-d H:i:s', $now),
|
||||
$mode,
|
||||
$today,
|
||||
$dryRun ? ' (演练模式,不写库)' : '',
|
||||
$explicitIds !== [] ? ' diagnosis_id=' . implode(',', $explicitIds) : ''
|
||||
));
|
||||
|
||||
// 候选
|
||||
if ($explicitIds !== []) {
|
||||
$ids = $explicitIds;
|
||||
} else {
|
||||
if (!$dryRun && !$this->acquireReentryGuard($mode, $now)) {
|
||||
$output->writeln(sprintf('同 mode 最近 %d 秒内已启动过一次,本次跳过。', self::REENTRY_GUARD_SECONDS));
|
||||
|
||||
return 0;
|
||||
}
|
||||
$sinceTs = $mode === 'incremental' ? $now - $sinceMinutes * 60 : 0;
|
||||
$ids = RevisitDueService::candidateDiagnosisIds($mode, $sinceTs);
|
||||
if ($mode === 'incremental') {
|
||||
$output->writeln(sprintf('增量窗口:自 %s 起', date('Y-m-d H:i:s', $sinceTs)));
|
||||
}
|
||||
}
|
||||
$total = \count($ids);
|
||||
$output->writeln('候选诊单:' . $total . ' 条');
|
||||
if ($total === 0) {
|
||||
$output->writeln('无需处理。');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$stats = ['refreshed' => 0, 'inserted' => 0, 'closed' => 0, 'reopened' => 0, 'no_days' => 0, 'chunks' => 0, 'errors' => 0];
|
||||
$printed = 0;
|
||||
$cfg = RevisitDueService::config();
|
||||
|
||||
foreach (array_chunk($ids, self::CHUNK_SIZE) as $chunk) {
|
||||
$stats['chunks']++;
|
||||
try {
|
||||
$res = RevisitDueService::refreshDiagnoses($chunk, [
|
||||
'reason' => 'cron:' . $mode,
|
||||
'dry_run' => $dryRun,
|
||||
'now' => $now,
|
||||
'today' => $today,
|
||||
]);
|
||||
foreach (['refreshed', 'inserted', 'closed', 'reopened', 'no_days'] as $k) {
|
||||
$stats[$k] += (int) ($res[$k] ?? 0);
|
||||
}
|
||||
if ($dryRun) {
|
||||
foreach ((array) ($res['rows'] ?? []) as $row) {
|
||||
if ($printed >= self::DRY_RUN_PRINT_LIMIT) {
|
||||
break;
|
||||
}
|
||||
$output->writeln($this->formatDryRunRow($row, $today, $cfg));
|
||||
$printed++;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$stats['errors']++;
|
||||
Log::error('tcm:revisit-due-scan 分块失败 mode=' . $mode . ' first_id=' . (int) ($chunk[0] ?? 0) . ' msg=' . $e->getMessage());
|
||||
$output->writeln('<error>分块失败(首 id ' . (int) ($chunk[0] ?? 0) . '):' . $e->getMessage() . '</error>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun && $total > $printed) {
|
||||
$output->writeln(sprintf('(演练仅打印前 %d 条,其余 %d 条未打印)', $printed, $total - $printed));
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
'完成。候选 %d,刷新 %d(新增 %d),自动关闭 %d,重开 %d,缺天数 %d,分块 %d(失败 %d),耗时 %ss',
|
||||
$total,
|
||||
$stats['refreshed'],
|
||||
$stats['inserted'],
|
||||
$stats['closed'],
|
||||
$stats['reopened'],
|
||||
$stats['no_days'],
|
||||
$stats['chunks'],
|
||||
$stats['errors'],
|
||||
round(microtime(true) - $startedAt, 2)
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('tcm:revisit-due-scan 执行异常: ' . $e->getMessage());
|
||||
$output->writeln('<error>执行异常:' . $e->getMessage() . '</error>');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 防重入:同 mode 最近一次开始时间距今 < 5 分钟则返回 false;否则记录本次开始时间。缓存异常时放行。
|
||||
*/
|
||||
private function acquireReentryGuard(string $mode, int $now): bool
|
||||
{
|
||||
$key = self::CACHE_KEY_PREFIX . $mode;
|
||||
try {
|
||||
$last = (int) Cache::get($key, 0);
|
||||
if ($last > 0 && $now - $last < self::REENTRY_GUARD_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
Cache::set($key, $now, self::REENTRY_GUARD_SECONDS);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('tcm:revisit-due-scan 防重入标记读写失败,放行: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parseIdList(string $raw): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $part) {
|
||||
$id = (int) $part;
|
||||
if ($id > 0 && !in_array($id, $out, true)) {
|
||||
$out[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function isValidDate(string $date): bool
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||
return false;
|
||||
}
|
||||
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
|
||||
return $d !== false && $d->format('Y-m-d') === $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* dry-run 单行输出:诊单 / 医助 / 诊次 / 锚点 / 天数 / 预计复诊 / 挂号 / 周期状态 / 推导状态 / 事件
|
||||
*
|
||||
* @param array<string, mixed> $row 拟写入行(含 _events/_is_new)
|
||||
* @param array<string, mixed> $cfg
|
||||
*/
|
||||
private function formatDryRunRow(array $row, string $today, array $cfg): string
|
||||
{
|
||||
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
|
||||
$anchorTs = (int) ($row['anchor_ts'] ?? 0);
|
||||
$events = (array) ($row['_events'] ?? []);
|
||||
|
||||
return sprintf(
|
||||
' diag=%d%s assistant=%d 患者=%s | 第%d诊(有效单%d,待复诊第%d诊) | 订单#%d %s | 锚点=%s(src=%d) 天数=%d(src=%d,档%d) 药尽=%s | 挂号=%d %s | closed=%d | %s[%s]%s%s',
|
||||
(int) ($row['diagnosis_id'] ?? 0),
|
||||
!empty($row['_is_new']) ? '(新增)' : '',
|
||||
(int) ($row['assistant_id'] ?? 0),
|
||||
(string) ($row['patient_name'] ?? ''),
|
||||
(int) ($row['current_slot'] ?? 0),
|
||||
(int) ($row['valid_order_count'] ?? 0),
|
||||
(int) ($row['next_slot'] ?? 0),
|
||||
(int) ($row['source_order_id'] ?? 0),
|
||||
(int) ($row['order_create_time'] ?? 0) > 0 ? date('Y-m-d', (int) $row['order_create_time']) : '-',
|
||||
$anchorTs > 0 ? date('Y-m-d', $anchorTs) : '-',
|
||||
(int) ($row['anchor_src'] ?? 0),
|
||||
(int) ($row['medication_days'] ?? 0),
|
||||
(int) ($row['days_src'] ?? 0),
|
||||
(int) ($row['rule_days'] ?? 0),
|
||||
(string) ($row['due_date'] ?? '') !== '' ? (string) $row['due_date'] : '-',
|
||||
(int) ($row['appointment_state'] ?? 0),
|
||||
(string) ($row['appointment_date'] ?? '') !== '' ? (string) $row['appointment_date'] : '-',
|
||||
(int) ($row['closed_state'] ?? 0),
|
||||
(string) $st['state'],
|
||||
(string) $st['state_text'],
|
||||
(int) $st['severe'] === 1 ? ' 深红' : '',
|
||||
$events !== [] ? ' events=' . implode(',', array_map('intval', $events)) : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace app\common\command;
|
||||
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\service\tcm\RevisitDueHook;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
@@ -55,6 +56,9 @@ class UpdateAppointmentStatus extends Command
|
||||
$missedCount = Db::execute($missedSql, [$now]);
|
||||
$cancelCount = Db::execute($cancelSql, [$now]);
|
||||
|
||||
// 过号/取消落库后重评复诊到期快照中「已约」行(当天过号即进标红/弹窗;钩子内部已 try/catch)
|
||||
RevisitDueHook::onAppointmentStatusCronDone();
|
||||
|
||||
$output->writeln("已过号(1→4): {$missedCount} 条");
|
||||
$output->writeln("已取消(4→2,过号超8小时): {$cancelCount} 条");
|
||||
Log::info("update_appointment_status: 已过号 {$missedCount}, 已取消 {$cancelCount}");
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 诊单复诊到期快照模型(每诊单一行,当前周期 = 最新有效业务订单)
|
||||
*
|
||||
* 写入方:RevisitDueService::refreshDiagnoses(cron / 钩子 / 列表自愈)与需求 4 的延迟/已联系/关闭接口。
|
||||
* 时间相关状态(到期/预警/超期/过号/已约)不落列,由 RevisitDueCalculator::deriveState 按事实列 + today 推导。
|
||||
*
|
||||
* @package app\common\model\tcm
|
||||
*/
|
||||
class DiagnosisRevisitDue extends BaseModel
|
||||
{
|
||||
protected $name = 'tcm_diagnosis_revisit_due';
|
||||
|
||||
// 自动时间戳类型设置为整型(表无 delete_time 列)
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_time';
|
||||
protected $updateTime = 'update_time';
|
||||
protected $deleteTime = false;
|
||||
|
||||
// 取出字段保持整型,前端按需要再格式化
|
||||
protected $dateFormat = false;
|
||||
|
||||
/** 周期状态:进行中 */
|
||||
public const CLOSED_OPEN = 0;
|
||||
/** 周期状态:已挂号(自动关闭,待面诊) */
|
||||
public const CLOSED_BOOKED = 2;
|
||||
/** 周期状态:已面诊(自动关闭) */
|
||||
public const CLOSED_VISITED = 3;
|
||||
/** 周期状态:人工关闭 */
|
||||
public const CLOSED_MANUAL = 4;
|
||||
/** 周期状态:失效(无医助 / 诊单删除或禁用 / 无有效订单) */
|
||||
public const CLOSED_INVALID = 5;
|
||||
|
||||
/** @var array<int,string> */
|
||||
public const CLOSED_STATE_TEXT_MAP = [
|
||||
self::CLOSED_OPEN => '进行中',
|
||||
self::CLOSED_BOOKED => '已挂号',
|
||||
self::CLOSED_VISITED => '已面诊',
|
||||
self::CLOSED_MANUAL => '人工关闭',
|
||||
self::CLOSED_INVALID => '失效',
|
||||
];
|
||||
|
||||
/** 用药起算来源:无 */
|
||||
public const ANCHOR_NONE = 0;
|
||||
/** 用药起算来源:物流签收 */
|
||||
public const ANCHOR_SIGN = 1;
|
||||
/** 用药起算来源:人工发货日志 */
|
||||
public const ANCHOR_SHIP = 2;
|
||||
/** 用药起算来源:下单时间 */
|
||||
public const ANCHOR_CREATE = 3;
|
||||
|
||||
/** @var array<int,string> */
|
||||
public const ANCHOR_SRC_TEXT_MAP = [
|
||||
self::ANCHOR_NONE => '',
|
||||
self::ANCHOR_SIGN => '签收起',
|
||||
self::ANCHOR_SHIP => '发货起',
|
||||
self::ANCHOR_CREATE => '下单起',
|
||||
];
|
||||
|
||||
/** 天数来源:缺失(或配置兜底) */
|
||||
public const DAYS_SRC_NONE = 0;
|
||||
/** 天数来源:订单 medication_days */
|
||||
public const DAYS_SRC_ORDER = 1;
|
||||
/** 天数来源:处方 usage_days */
|
||||
public const DAYS_SRC_RX = 2;
|
||||
|
||||
/** @var array<int,string> */
|
||||
public const DAYS_SRC_TEXT_MAP = [
|
||||
self::DAYS_SRC_NONE => '缺开药天数',
|
||||
self::DAYS_SRC_ORDER => '订单',
|
||||
self::DAYS_SRC_RX => '处方',
|
||||
];
|
||||
|
||||
/** 锚点订单之后最新有效挂号状态:无 */
|
||||
public const APPT_NONE = 0;
|
||||
/** 已约(doctor_appointment.status=1) */
|
||||
public const APPT_BOOKED = 1;
|
||||
/** 已面诊(status=3) */
|
||||
public const APPT_VISITED = 3;
|
||||
/** 已过号(status=4) */
|
||||
public const APPT_MISSED = 4;
|
||||
|
||||
/** @var array<int,string> */
|
||||
public const APPOINTMENT_STATE_TEXT_MAP = [
|
||||
self::APPT_NONE => '未挂号',
|
||||
self::APPT_BOOKED => '已约',
|
||||
self::APPT_VISITED => '已面诊',
|
||||
self::APPT_MISSED => '已过号',
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 周期状态文本
|
||||
*/
|
||||
public function getClosedStateTextAttr($value, $data): string
|
||||
{
|
||||
$v = (int) ($data['closed_state'] ?? 0);
|
||||
|
||||
return self::CLOSED_STATE_TEXT_MAP[$v] ?? '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 用药起算来源文本
|
||||
*/
|
||||
public function getAnchorSrcTextAttr($value, $data): string
|
||||
{
|
||||
$v = (int) ($data['anchor_src'] ?? 0);
|
||||
|
||||
return self::ANCHOR_SRC_TEXT_MAP[$v] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 开药天数来源文本
|
||||
*/
|
||||
public function getDaysSrcTextAttr($value, $data): string
|
||||
{
|
||||
$v = (int) ($data['days_src'] ?? 0);
|
||||
|
||||
return self::DAYS_SRC_TEXT_MAP[$v] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 挂号事实状态文本
|
||||
*/
|
||||
public function getAppointmentStateTextAttr($value, $data): string
|
||||
{
|
||||
$v = (int) ($data['appointment_state'] ?? 0);
|
||||
|
||||
return self::APPOINTMENT_STATE_TEXT_MAP[$v] ?? '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 最近重算时间格式化(Y-m-d H:i)
|
||||
*/
|
||||
public function getComputedAtTextAttr($value, $data): string
|
||||
{
|
||||
$ts = (int) ($data['computed_at'] ?? 0);
|
||||
|
||||
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 下次提醒时间格式化(Y-m-d H:i)
|
||||
*/
|
||||
public function getNextRemindAtTextAttr($value, $data): string
|
||||
{
|
||||
$ts = (int) ($data['next_remind_at'] ?? 0);
|
||||
|
||||
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 用药起算日(Y-m-d)
|
||||
*/
|
||||
public function getAnchorDateAttr($value, $data): string
|
||||
{
|
||||
$ts = (int) ($data['anchor_ts'] ?? 0);
|
||||
|
||||
return $ts > 0 ? date('Y-m-d', $ts) : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 诊单复诊到期事件流水模型(审计与「越点越上心」统计)
|
||||
*
|
||||
* 注意:弹窗轮询不写流水(只更新 first/last_shown_at),避免日志膨胀。
|
||||
*
|
||||
* @package app\common\model\tcm
|
||||
*/
|
||||
class DiagnosisRevisitDueLog extends BaseModel
|
||||
{
|
||||
protected $name = 'tcm_diagnosis_revisit_due_log';
|
||||
|
||||
// 仅有 create_time,整型时间戳
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_time';
|
||||
protected $updateTime = false;
|
||||
protected $deleteTime = false;
|
||||
|
||||
protected $dateFormat = false;
|
||||
|
||||
/** 事件:延迟 */
|
||||
public const EVENT_SNOOZE = 1;
|
||||
/** 事件:已联系(填承诺日) */
|
||||
public const EVENT_CONTACT = 2;
|
||||
/** 事件:人工关闭 */
|
||||
public const EVENT_MANUAL_CLOSE = 3;
|
||||
/** 事件:自动关闭-已挂号 */
|
||||
public const EVENT_AUTO_CLOSE_BOOKED = 4;
|
||||
/** 事件:自动关闭-已面诊 */
|
||||
public const EVENT_AUTO_CLOSE_VISITED = 5;
|
||||
/** 事件:周期重置(新有效订单成为锚点) */
|
||||
public const EVENT_CYCLE_RESET = 6;
|
||||
/** 事件:重新打开(挂号取消/过号) */
|
||||
public const EVENT_REOPEN = 7;
|
||||
/** 事件:企微推送 */
|
||||
public const EVENT_WECOM_PUSH = 8;
|
||||
/** 事件:医助变更(含继承),延迟字段清零 */
|
||||
public const EVENT_ASSISTANT_CHANGED = 9;
|
||||
/** 事件:形态升级(escalation_level 变化) */
|
||||
public const EVENT_ESCALATED = 10;
|
||||
/** 事件:首次弹出 */
|
||||
public const EVENT_FIRST_SHOWN = 11;
|
||||
|
||||
/** @var array<int,string> */
|
||||
public const EVENT_TEXT_MAP = [
|
||||
self::EVENT_SNOOZE => '延迟',
|
||||
self::EVENT_CONTACT => '已联系',
|
||||
self::EVENT_MANUAL_CLOSE => '人工关闭',
|
||||
self::EVENT_AUTO_CLOSE_BOOKED => '自动关闭-已挂号',
|
||||
self::EVENT_AUTO_CLOSE_VISITED => '自动关闭-已面诊',
|
||||
self::EVENT_CYCLE_RESET => '周期重置',
|
||||
self::EVENT_REOPEN => '重新打开',
|
||||
self::EVENT_WECOM_PUSH => '企微推送',
|
||||
self::EVENT_ASSISTANT_CHANGED => '医助变更',
|
||||
self::EVENT_ESCALATED => '形态升级',
|
||||
self::EVENT_FIRST_SHOWN => '首次弹出',
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 事件文本
|
||||
*/
|
||||
public function getEventTextAttr($value, $data): string
|
||||
{
|
||||
$v = (int) ($data['event'] ?? 0);
|
||||
|
||||
return self::EVENT_TEXT_MAP[$v] ?? '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 事件时间格式化(Y-m-d H:i:s)
|
||||
*/
|
||||
public function getCreateTimeTextAttr($value, $data): string
|
||||
{
|
||||
$ts = (int) ($data['create_time'] ?? 0);
|
||||
|
||||
return $ts > 0 ? date('Y-m-d H:i:s', $ts) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes payload JSON 解码为数组(解码失败返回空数组)
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function getPayloadArrAttr($value, $data): array
|
||||
{
|
||||
$raw = (string) ($data['payload'] ?? '');
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\tcm;
|
||||
|
||||
/**
|
||||
* 复诊到期纯函数计算器(无 DB、无 config() 依赖,可直接单测)。
|
||||
*
|
||||
* - computeFacts():由「最新有效订单 + 处方天数 + 签收/发货时间 + 挂号 + 首次触达」算出快照表事实列(方案 §1.2);
|
||||
* - deriveState():由快照行事实列 + today 推导展示状态(方案 §1.3 九态与优先级),列表 / 弹窗 / 主管视图共用;
|
||||
* - escalationLevel():延迟次数 → 升级等级(方案 §1.4)。
|
||||
*
|
||||
* 约定:所有日期为 'Y-m-d' 字符串(或 null),时间戳为 int 秒;配置缺项用 defaultConfig() 补齐。
|
||||
*/
|
||||
final class RevisitDueCalculator
|
||||
{
|
||||
public const STATE_VISITED = 'visited';
|
||||
public const STATE_BOOKED = 'booked';
|
||||
public const STATE_MISSED = 'missed';
|
||||
public const STATE_CONTACTED = 'contacted';
|
||||
public const STATE_OVERDUE = 'overdue';
|
||||
public const STATE_WARNING = 'warning';
|
||||
public const STATE_PENDING = 'pending';
|
||||
public const STATE_NO_DAYS = 'no_days';
|
||||
public const STATE_NONE = 'none';
|
||||
|
||||
/** 起算来源:1=签收 2=发货 3=下单 0=无 */
|
||||
public const ANCHOR_NONE = 0;
|
||||
public const ANCHOR_SIGN = 1;
|
||||
public const ANCHOR_SHIP = 2;
|
||||
public const ANCHOR_CREATE = 3;
|
||||
|
||||
/** 天数来源:0=缺失(或配置兜底) 1=订单 2=处方 */
|
||||
public const DAYS_SRC_NONE = 0;
|
||||
public const DAYS_SRC_ORDER = 1;
|
||||
public const DAYS_SRC_RX = 2;
|
||||
|
||||
/** 周期状态(与 DiagnosisRevisitDue::CLOSED_* 一致) */
|
||||
public const CLOSED_OPEN = 0;
|
||||
public const CLOSED_BOOKED = 2;
|
||||
public const CLOSED_VISITED = 3;
|
||||
public const CLOSED_MANUAL = 4;
|
||||
public const CLOSED_INVALID = 5;
|
||||
|
||||
/** 挂号事实状态:0=无 1=已约 3=已面诊 4=过号 */
|
||||
public const APPT_NONE = 0;
|
||||
public const APPT_BOOKED = 1;
|
||||
public const APPT_VISITED = 3;
|
||||
public const APPT_MISSED = 4;
|
||||
|
||||
/** 偏移量上限(与 RevisitVisitSlotService::MAX_OFFSET 一致) */
|
||||
private const MAX_OFFSET = 20;
|
||||
|
||||
/**
|
||||
* 默认配置(与 config/project.php 的 revisit_reach 段同键;此处只含计算相关项)。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function defaultConfig(): array
|
||||
{
|
||||
return [
|
||||
'anchor_chain' => ['sign', 'ship', 'create'],
|
||||
'days_fallback_rx' => true,
|
||||
'days_default' => 0,
|
||||
'day_buckets' => [14, 21, 28, 30],
|
||||
'grace_days' => 0,
|
||||
'warn_days' => 3,
|
||||
'severe_overdue_days' => 7,
|
||||
'appointment_min_gap_days' => 1,
|
||||
'limit_er_center' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并默认值并做类型归一(容忍 .env / 配置里写成字符串)。
|
||||
*
|
||||
* @param array<string, mixed> $cfg
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function normalizeConfig(array $cfg): array
|
||||
{
|
||||
$out = array_replace(self::defaultConfig(), $cfg);
|
||||
|
||||
$chain = $out['anchor_chain'];
|
||||
if (is_string($chain)) {
|
||||
$chain = explode(',', $chain);
|
||||
}
|
||||
if (!is_array($chain)) {
|
||||
$chain = ['create'];
|
||||
}
|
||||
$normChain = [];
|
||||
foreach ($chain as $c) {
|
||||
$c = strtolower(trim((string) $c));
|
||||
if (in_array($c, ['sign', 'ship', 'create'], true) && !in_array($c, $normChain, true)) {
|
||||
$normChain[] = $c;
|
||||
}
|
||||
}
|
||||
$out['anchor_chain'] = $normChain;
|
||||
|
||||
$buckets = is_array($out['day_buckets']) ? $out['day_buckets'] : [];
|
||||
$out['day_buckets'] = array_values(array_unique(array_filter(array_map('intval', $buckets), static fn (int $v): bool => $v > 0)));
|
||||
|
||||
$out['days_fallback_rx'] = (bool) $out['days_fallback_rx'];
|
||||
$out['days_default'] = max(0, (int) $out['days_default']);
|
||||
$out['grace_days'] = max(0, (int) $out['grace_days']);
|
||||
$out['warn_days'] = max(0, (int) $out['warn_days']);
|
||||
$out['appointment_min_gap_days'] = max(0, (int) ($out['appointment_min_gap_days'] ?? 1));
|
||||
$out['limit_er_center'] = (bool) $out['limit_er_center'];
|
||||
|
||||
// severe_overdue_days 允许阶梯数组:取最小阈值作为「深红」起点
|
||||
$severe = $out['severe_overdue_days'];
|
||||
if (is_array($severe)) {
|
||||
$vals = array_filter(array_map('intval', $severe), static fn (int $v): bool => $v > 0);
|
||||
$severe = $vals !== [] ? min($vals) : 7;
|
||||
}
|
||||
$out['severe_overdue_days'] = max(0, (int) $severe);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算快照事实列。
|
||||
*
|
||||
* @param array{
|
||||
* diagnosis_id:int, assistant_id:int, patient_name?:string,
|
||||
* latest_order:null|array{id:int, create_time:int, medication_days?:int, prescription_id?:int},
|
||||
* valid_order_count:int, offset:int, rx_usage_days?:int|null,
|
||||
* sign_ts?:int|null, ship_ts?:int|null,
|
||||
* appointments?:list<array{id:int, status:int, appointment_date:string, appointment_time?:string}>,
|
||||
* first_touch_ts?:int|null, total_medication_days?:int, diagnosis_active?:bool
|
||||
* } $input
|
||||
* @param array<string, mixed> $cfg revisit_reach 配置(可只传部分键)
|
||||
* @param string $today 'Y-m-d',用于判定「已约」是否仍在未来
|
||||
*
|
||||
* @return array{
|
||||
* source_order_id:int, order_create_time:int, valid_order_count:int,
|
||||
* anchor_ts:int, anchor_src:int, medication_days:int, days_src:int, rule_days:int,
|
||||
* total_medication_days:int, current_slot:int, next_slot:int,
|
||||
* first_touch_date:string|null, due_date:string|null,
|
||||
* appointment_state:int, appointment_date:string|null, booked_appointment_id:int, closed_state_auto:int
|
||||
* }
|
||||
*/
|
||||
public static function computeFacts(array $input, array $cfg, string $today): array
|
||||
{
|
||||
$cfg = self::normalizeConfig($cfg);
|
||||
|
||||
$assistantId = (int) ($input['assistant_id'] ?? 0);
|
||||
$validCount = max(0, (int) ($input['valid_order_count'] ?? 0));
|
||||
$offset = (int) ($input['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
if ($offset > self::MAX_OFFSET) {
|
||||
$offset = self::MAX_OFFSET;
|
||||
}
|
||||
$currentSlot = $validCount + $offset;
|
||||
$firstTouchTs = (int) ($input['first_touch_ts'] ?? 0);
|
||||
$diagnosisActive = (bool) ($input['diagnosis_active'] ?? true);
|
||||
|
||||
$order = $input['latest_order'] ?? null;
|
||||
if (!is_array($order) || (int) ($order['id'] ?? 0) <= 0) {
|
||||
$order = null;
|
||||
}
|
||||
|
||||
$facts = [
|
||||
'source_order_id' => 0,
|
||||
'order_create_time' => 0,
|
||||
'valid_order_count' => $validCount,
|
||||
'anchor_ts' => 0,
|
||||
'anchor_src' => self::ANCHOR_NONE,
|
||||
'medication_days' => 0,
|
||||
'days_src' => self::DAYS_SRC_NONE,
|
||||
'rule_days' => 0,
|
||||
'total_medication_days' => max(0, (int) ($input['total_medication_days'] ?? 0)),
|
||||
'current_slot' => $currentSlot,
|
||||
'next_slot' => $currentSlot + 1,
|
||||
'first_touch_date' => $firstTouchTs > 0 ? date('Y-m-d', $firstTouchTs) : null,
|
||||
'due_date' => null,
|
||||
'appointment_state' => self::APPT_NONE,
|
||||
'appointment_date' => null,
|
||||
'booked_appointment_id' => 0,
|
||||
'closed_state_auto' => self::CLOSED_INVALID,
|
||||
];
|
||||
|
||||
// 锚点订单先于失效判定写入:失效行(无医助/禁用/删除)也记录最新有效订单,
|
||||
// 否则列表自愈会因 source_order_id=0 ≠ 最新订单 id 而在每次加载时重复刷新这些行
|
||||
$orderCreateTime = $order !== null ? (int) ($order['create_time'] ?? 0) : 0;
|
||||
$facts['source_order_id'] = $order !== null ? (int) ($order['id'] ?? 0) : 0;
|
||||
$facts['order_create_time'] = $orderCreateTime;
|
||||
|
||||
// 无有效订单 / 无医助 / 诊单删除或禁用 → 失效周期
|
||||
if ($order === null || $validCount <= 0 || $assistantId <= 0 || !$diagnosisActive) {
|
||||
return $facts;
|
||||
}
|
||||
|
||||
// 1. 用药起算锚点:按 anchor_chain 回退(签收 → 发货 → 下单);链耗尽仍无结果时兜底下单时间
|
||||
$signTs = (int) ($input['sign_ts'] ?? 0);
|
||||
$shipTs = (int) ($input['ship_ts'] ?? 0);
|
||||
foreach ($cfg['anchor_chain'] as $src) {
|
||||
if ($src === 'sign' && $signTs > 0) {
|
||||
$facts['anchor_ts'] = $signTs;
|
||||
$facts['anchor_src'] = self::ANCHOR_SIGN;
|
||||
break;
|
||||
}
|
||||
if ($src === 'ship' && $shipTs > 0) {
|
||||
$facts['anchor_ts'] = $shipTs;
|
||||
$facts['anchor_src'] = self::ANCHOR_SHIP;
|
||||
break;
|
||||
}
|
||||
if ($src === 'create' && $orderCreateTime > 0) {
|
||||
$facts['anchor_ts'] = $orderCreateTime;
|
||||
$facts['anchor_src'] = self::ANCHOR_CREATE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($facts['anchor_ts'] <= 0 && $orderCreateTime > 0) {
|
||||
$facts['anchor_ts'] = $orderCreateTime;
|
||||
$facts['anchor_src'] = self::ANCHOR_CREATE;
|
||||
}
|
||||
|
||||
// 2. 开药天数:订单 medication_days → 处方 usage_days(已剔系统代开/作废)→ 配置兜底(days_default,src 仍记 0)
|
||||
$orderDays = (int) ($order['medication_days'] ?? 0);
|
||||
$rxDays = (int) ($input['rx_usage_days'] ?? 0);
|
||||
if ($orderDays > 0) {
|
||||
$facts['medication_days'] = $orderDays;
|
||||
$facts['days_src'] = self::DAYS_SRC_ORDER;
|
||||
} elseif ($cfg['days_fallback_rx'] && $rxDays > 0) {
|
||||
$facts['medication_days'] = $rxDays;
|
||||
$facts['days_src'] = self::DAYS_SRC_RX;
|
||||
} elseif ($cfg['days_default'] > 0) {
|
||||
$facts['medication_days'] = (int) $cfg['days_default'];
|
||||
$facts['days_src'] = self::DAYS_SRC_NONE;
|
||||
}
|
||||
|
||||
// 3. 档位:仅当天数恰好命中 day_buckets 才归档(15 天不归 14),只用于筛选/展示
|
||||
if ($facts['medication_days'] > 0 && in_array($facts['medication_days'], $cfg['day_buckets'], true)) {
|
||||
$facts['rule_days'] = $facts['medication_days'];
|
||||
}
|
||||
|
||||
// 4. 预计复诊日 = DATE(anchor_ts) + medication_days + grace_days;缺天数为 null
|
||||
if ($facts['anchor_ts'] > 0 && $facts['medication_days'] > 0) {
|
||||
$facts['due_date'] = self::addDays(date('Y-m-d', $facts['anchor_ts']), $facts['medication_days'] + (int) $cfg['grace_days']);
|
||||
}
|
||||
|
||||
// 5. 最新有效挂号 A*(status ∈ 1,3,4;按 appointment_date, appointment_time, id 取最新)
|
||||
// 只认「下单日 + appointment_min_gap_days(默认 1,即次日起)」之后的挂号:
|
||||
// 实际流程多为「挂号 → 面诊 → 开方 → 当天下单」,同一天(及更早)的面诊是产生这笔药的那次面诊,
|
||||
// 不是这笔药之后的复诊,若计入会把周期立刻关闭、患者到期后永远不被提醒。
|
||||
$orderDate = $orderCreateTime > 0 ? date('Y-m-d', $orderCreateTime) : '';
|
||||
$minAptDate = $orderDate !== '' ? self::addDays($orderDate, (int) $cfg['appointment_min_gap_days']) : '';
|
||||
$best = null;
|
||||
$bestKey = '';
|
||||
foreach ((array) ($input['appointments'] ?? []) as $apt) {
|
||||
if (!is_array($apt)) {
|
||||
continue;
|
||||
}
|
||||
$status = (int) ($apt['status'] ?? 0);
|
||||
if (!in_array($status, [self::APPT_BOOKED, self::APPT_VISITED, self::APPT_MISSED], true)) {
|
||||
continue;
|
||||
}
|
||||
$aptDate = substr(trim((string) ($apt['appointment_date'] ?? '')), 0, 10);
|
||||
if ($aptDate === '' || ($minAptDate !== '' && $aptDate < $minAptDate)) {
|
||||
continue;
|
||||
}
|
||||
$key = $aptDate . ' ' . self::normalizeTime((string) ($apt['appointment_time'] ?? '')) . ' ' . sprintf('%012d', (int) ($apt['id'] ?? 0));
|
||||
if ($best === null || strcmp($key, $bestKey) > 0) {
|
||||
$best = $apt;
|
||||
$bestKey = $key;
|
||||
$best['_date'] = $aptDate;
|
||||
$best['_status'] = $status;
|
||||
}
|
||||
}
|
||||
if ($best !== null) {
|
||||
$facts['appointment_state'] = (int) $best['_status'];
|
||||
$facts['appointment_date'] = (string) $best['_date'];
|
||||
$facts['booked_appointment_id'] = (int) ($best['id'] ?? 0);
|
||||
}
|
||||
|
||||
// 6. 自动周期状态:已面诊 → 3;已约且日期未过 → 2;其余(无挂号/过号/已约已过期/缺天数)保持进行中 0
|
||||
if ($facts['appointment_state'] === self::APPT_VISITED) {
|
||||
$facts['closed_state_auto'] = self::CLOSED_VISITED;
|
||||
} elseif ($facts['appointment_state'] === self::APPT_BOOKED && $facts['appointment_date'] !== null && $facts['appointment_date'] >= $today) {
|
||||
$facts['closed_state_auto'] = self::CLOSED_BOOKED;
|
||||
} else {
|
||||
$facts['closed_state_auto'] = self::CLOSED_OPEN;
|
||||
}
|
||||
|
||||
return $facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由快照行 + today 推导展示状态(方案 §1.3)。
|
||||
*
|
||||
* 优先级:visited > booked > missed > contacted > overdue > warning > pending > no_days > none。
|
||||
* missed 不受 due_date 限制(到期日前挂号并过号同样标红);人工关闭(closed_state=4) 的行保留事实状态但不深红,文案前缀「已关闭·」。
|
||||
*
|
||||
* @param array<string, mixed> $row 快照行(含 closed_state/appointment_state/appointment_date/due_date/promised_date/valid_order_count/source_order_id/assistant_id)
|
||||
* @param array<string, mixed> $cfg revisit_reach 配置
|
||||
*
|
||||
* @return array{state:string, state_text:string, days_left:int|null, overdue_days:int|null, severe:int}
|
||||
*/
|
||||
public static function deriveState(array $row, string $today, array $cfg): array
|
||||
{
|
||||
$cfg = self::normalizeConfig($cfg);
|
||||
$warnDays = (int) $cfg['warn_days'];
|
||||
$severeDays = (int) $cfg['severe_overdue_days'];
|
||||
|
||||
$closed = (int) ($row['closed_state'] ?? self::CLOSED_OPEN);
|
||||
$apptState = (int) ($row['appointment_state'] ?? self::APPT_NONE);
|
||||
$apptDate = self::nullableDate($row['appointment_date'] ?? null);
|
||||
$dueDate = self::nullableDate($row['due_date'] ?? null);
|
||||
$promised = self::nullableDate($row['promised_date'] ?? null);
|
||||
$validCount = (int) ($row['valid_order_count'] ?? 0);
|
||||
$sourceOrderId = (int) ($row['source_order_id'] ?? 0);
|
||||
$assistantId = (int) ($row['assistant_id'] ?? 0);
|
||||
|
||||
$none = ['state' => self::STATE_NONE, 'state_text' => '', 'days_left' => null, 'overdue_days' => null, 'severe' => 0];
|
||||
if ($validCount <= 0 || $sourceOrderId <= 0 || $assistantId <= 0 || $closed === self::CLOSED_INVALID) {
|
||||
return $none;
|
||||
}
|
||||
|
||||
$daysLeft = $dueDate !== null ? self::dayDiff($today, $dueDate) : null;
|
||||
$overdueDays = $daysLeft !== null ? max(0, -$daysLeft) : null;
|
||||
$severe = ($overdueDays !== null && $severeDays > 0 && $overdueDays >= $severeDays) ? 1 : 0;
|
||||
|
||||
$state = self::STATE_NONE;
|
||||
$text = '';
|
||||
if ($apptState === self::APPT_VISITED) {
|
||||
$state = self::STATE_VISITED;
|
||||
$text = '已面诊' . self::mdSuffix($apptDate);
|
||||
$severe = 0;
|
||||
} elseif ($apptState === self::APPT_BOOKED && ($apptDate === null || $apptDate >= $today)) {
|
||||
$state = self::STATE_BOOKED;
|
||||
$text = '已约' . self::mdSuffix($apptDate);
|
||||
$severe = 0;
|
||||
} elseif ($apptState === self::APPT_MISSED || ($apptState === self::APPT_BOOKED && $apptDate !== null && $apptDate < $today)) {
|
||||
$state = self::STATE_MISSED;
|
||||
$text = '过号' . self::mdSuffix($apptDate);
|
||||
} elseif ($promised !== null && $promised >= $today) {
|
||||
$state = self::STATE_CONTACTED;
|
||||
$text = '已联系·约 ' . substr($promised, 5);
|
||||
$severe = 0;
|
||||
} elseif ($dueDate === null || $daysLeft === null) {
|
||||
$state = self::STATE_NO_DAYS;
|
||||
$text = '缺开药天数';
|
||||
$severe = 0;
|
||||
} elseif ($daysLeft <= 0) {
|
||||
$state = self::STATE_OVERDUE;
|
||||
$text = $overdueDays === 0 ? '今日应挂号' : '超期 ' . $overdueDays . ' 天';
|
||||
} elseif ($daysLeft <= $warnDays) {
|
||||
$state = self::STATE_WARNING;
|
||||
$text = '剩 ' . $daysLeft . ' 天';
|
||||
$severe = 0;
|
||||
} else {
|
||||
$state = self::STATE_PENDING;
|
||||
$text = '剩 ' . $daysLeft . ' 天';
|
||||
$severe = 0;
|
||||
}
|
||||
|
||||
if ($closed === self::CLOSED_MANUAL && in_array($state, [self::STATE_MISSED, self::STATE_OVERDUE, self::STATE_WARNING, self::STATE_PENDING, self::STATE_NO_DAYS], true)) {
|
||||
$text = '已关闭·' . $text;
|
||||
$severe = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => $state,
|
||||
'state_text' => $text,
|
||||
'days_left' => $daysLeft,
|
||||
'overdue_days' => $overdueDays,
|
||||
'severe' => $severe,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟次数 → 升级等级:0→0,1~2→1,3~4→2,≥5→3(方案 §1.4)。
|
||||
*/
|
||||
public static function escalationLevel(int $snoozeCount): int
|
||||
{
|
||||
if ($snoozeCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if ($snoozeCount <= 2) {
|
||||
return 1;
|
||||
}
|
||||
if ($snoozeCount <= 4) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个 'Y-m-d' 日期的天数差:to − from(负数表示 to 早于 from);任一非法返回 0。
|
||||
*/
|
||||
public static function dayDiff(string $from, string $to): int
|
||||
{
|
||||
$a = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($from), 0, 10));
|
||||
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($to), 0, 10));
|
||||
if ($a === false || $b === false) {
|
||||
return 0;
|
||||
}
|
||||
$diff = $a->diff($b);
|
||||
|
||||
return (int) $diff->days * ($diff->invert === 1 ? -1 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Y-m-d' 加 N 天(N 可为负);非法日期原样返回。
|
||||
*/
|
||||
public static function addDays(string $date, int $days): string
|
||||
{
|
||||
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($date), 0, 10));
|
||||
if ($d === false) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
return $d->modify(sprintf('%+d days', $days))->format('Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号时间归一:空串/NULL → '00:00:00','HH:MM' → 'HH:MM:00'(用于字符串排序)。
|
||||
*/
|
||||
private static function normalizeTime(string $time): string
|
||||
{
|
||||
$t = trim($time);
|
||||
if ($t === '' || $t === '0' || $t === '00:00') {
|
||||
return '00:00:00';
|
||||
}
|
||||
if (preg_match('/^\d{1,2}:\d{2}$/', $t) === 1) {
|
||||
$t .= ':00';
|
||||
}
|
||||
if (preg_match('/^(\d{1,2}):(\d{2}):(\d{2})$/', $t, $m) === 1) {
|
||||
return sprintf('%02d:%02d:%02d', (int) $m[1], (int) $m[2], (int) $m[3]);
|
||||
}
|
||||
|
||||
return $t;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期列归一:空串 / '0000-00-00' / null → null,其余截取前 10 位。
|
||||
*/
|
||||
private static function nullableDate(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$s = substr(trim((string) $value), 0, 10);
|
||||
if ($s === '' || $s === '0000-00-00') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
/** 'Y-m-d' → ' MM-DD'(null 返回空串),用于 chip 文案 */
|
||||
private static function mdSuffix(?string $date): string
|
||||
{
|
||||
return $date !== null ? ' ' . substr($date, 5) : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\tcm;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 复诊到期快照业务钩子(订单 / 挂号 / 指派 / 诊单变更 / 过号 cron)。
|
||||
*
|
||||
* 所有方法整体 try/catch 并 Log::warning,绝不向调用方抛异常、不改变主流程返回值;
|
||||
* 任一钩子漏接由 30 分钟增量 cron(tcm:revisit-due-scan --mode=incremental)与列表自愈兜底。
|
||||
*
|
||||
* 接入点(由其它代理在业务 Logic 的事务提交之后调用):
|
||||
* - PrescriptionOrderLogic create/editTime/withdraw/complete(9,10)/refund、GancaoCallbackController case 91/92 → onOrderChanged
|
||||
* - AppointmentLogic create/cancel/complete/adminEdit → onAppointmentChanged
|
||||
* - DiagnosisLogic::assign、AutoAssignPendingDiagnosis、AutoAssignLogLogic 回退 → onAssistantChanged
|
||||
* - DiagnosisLogic::setRevisitSlotStartOffset 等诊单字段变更 → onDiagnosisChanged
|
||||
* - UpdateAppointmentStatus 两条 Db::execute 之后 → onAppointmentStatusCronDone
|
||||
*/
|
||||
final class RevisitDueHook
|
||||
{
|
||||
/**
|
||||
* 业务订单变化(新建 / 改下单时间 / 撤回 / 完成为 9,10 / 退款 / 甘草回调置 4)。
|
||||
* diagnosisId 为 0 时按 orderId 反查诊单。
|
||||
*/
|
||||
public static function onOrderChanged(int $diagnosisId, int $orderId = 0, string $reason = ''): void
|
||||
{
|
||||
try {
|
||||
if ($diagnosisId <= 0 && $orderId > 0) {
|
||||
$diagnosisId = (int) Db::name('tcm_prescription_order')->where('id', $orderId)->value('diagnosis_id');
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return;
|
||||
}
|
||||
RevisitDueService::refreshDiagnoses([$diagnosisId], [
|
||||
'reason' => self::reasonTag('order', $reason),
|
||||
'operator_id' => self::currentAdminId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('复诊到期钩子 onOrderChanged 失败 diagnosis_id=%d order_id=%d: %s', $diagnosisId, $orderId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号变化(创建 / 取消 / 完成 / 后台编辑)。diagnosisId 为 0 时按 appointmentId 反查(patient_id 存诊单 id)。
|
||||
*/
|
||||
public static function onAppointmentChanged(int $diagnosisId, int $appointmentId = 0, string $reason = ''): void
|
||||
{
|
||||
try {
|
||||
if ($diagnosisId <= 0 && $appointmentId > 0) {
|
||||
$diagnosisId = (int) Db::name('doctor_appointment')->where('id', $appointmentId)->value('patient_id');
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return;
|
||||
}
|
||||
RevisitDueService::refreshDiagnoses([$diagnosisId], [
|
||||
'reason' => self::reasonTag('appointment', $reason),
|
||||
'operator_id' => self::currentAdminId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('复诊到期钩子 onAppointmentChanged 失败 diagnosis_id=%d appointment_id=%d: %s', $diagnosisId, $appointmentId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助变更(指派 / 继承 / 释放 to=0 / 自动分配 / 回退)。toAssistantId 仅用于流水 payload,实际以库内 tcm_diagnosis.assistant_id 为准。
|
||||
*/
|
||||
public static function onAssistantChanged(int $diagnosisId, int $toAssistantId, string $reason = ''): void
|
||||
{
|
||||
try {
|
||||
if ($diagnosisId <= 0) {
|
||||
return;
|
||||
}
|
||||
$tag = self::reasonTag('assign', $reason);
|
||||
if ($toAssistantId >= 0) {
|
||||
$tag .= ':to=' . $toAssistantId;
|
||||
}
|
||||
RevisitDueService::refreshDiagnoses([$diagnosisId], [
|
||||
'reason' => $tag,
|
||||
'operator_id' => self::currentAdminId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('复诊到期钩子 onAssistantChanged 失败 diagnosis_id=%d to=%d: %s', $diagnosisId, $toAssistantId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单其它字段变更(如 revisit_slot_start_offset、status 禁用/启用、软删)。
|
||||
*/
|
||||
public static function onDiagnosisChanged(int $diagnosisId, string $reason = ''): void
|
||||
{
|
||||
try {
|
||||
if ($diagnosisId <= 0) {
|
||||
return;
|
||||
}
|
||||
RevisitDueService::refreshDiagnoses([$diagnosisId], [
|
||||
'reason' => self::reasonTag('diagnosis', $reason),
|
||||
'operator_id' => self::currentAdminId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('复诊到期钩子 onDiagnosisChanged 失败 diagnosis_id=%d: %s', $diagnosisId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过号 cron(update_appointment_status)执行之后:重评 closed_state=2 / appointment_state=1 的行,使当天过号即进标红/弹窗。
|
||||
*/
|
||||
public static function onAppointmentStatusCronDone(): void
|
||||
{
|
||||
try {
|
||||
RevisitDueService::reevaluateBookedRows();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('复诊到期钩子 onAppointmentStatusCronDone 失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 流水 payload 的 reason 标签:<来源>[:<说明>],限 64 字符 */
|
||||
private static function reasonTag(string $source, string $reason): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
return mb_substr($reason !== '' ? $source . ':' . $reason : $source, 0, 64);
|
||||
}
|
||||
|
||||
/** 当前登录管理员 id(CLI / 回调无登录态时为 0),任何异常均视为 0 */
|
||||
private static function currentAdminId(): int
|
||||
{
|
||||
try {
|
||||
$req = request();
|
||||
$admin = $req->adminInfo ?? null;
|
||||
if (is_array($admin)) {
|
||||
return (int) ($admin['admin_id'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// CLI 场景无请求上下文,忽略
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\tcm;
|
||||
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 诊次(第几诊)口径单点实现,复诊接诊率 / 诊单列表「复诊节点」/ 到期快照共用。
|
||||
*
|
||||
* 口径(与 RevisitRateLogic 完全一致):
|
||||
* - 有效业务订单 E(D):tcm_prescription_order 中 delete_time IS NULL 且 fulfillment_status IS NULL OR NOT IN (4,9,10)
|
||||
* (统一经 YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery),按 (create_time, id) 升序;
|
||||
* - 偏移 off(D) = tcm_diagnosis.revisit_slot_start_offset 钳到 0~20;
|
||||
* - 第 n 笔有效订单的诊次 = n + off(D);患者当前已完成诊次 current_slot = |E(D)| + off(D);待复诊诊次 next_slot = current_slot + 1;
|
||||
* - 某时刻 t 患者处于第几诊 slot_at(D, t) = |{o : create_time(o) < t}| + off(D)(严格小于,与持有人规则 t <= ct 对偶)。
|
||||
*
|
||||
* MySQL 5.7 无窗口函数:batchCurrentSlot 先 GROUP BY 聚合,再按 (diagnosis_id, create_time) 回表,PHP 侧取 id 最大的一行。
|
||||
*/
|
||||
final class RevisitVisitSlotService
|
||||
{
|
||||
/** 偏移量上限(与 RevisitRateLogic 历史实现一致) */
|
||||
public const MAX_OFFSET = 20;
|
||||
|
||||
/** 单次 SQL 的诊单分块大小 */
|
||||
private const CHUNK_SIZE = 2000;
|
||||
|
||||
/**
|
||||
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
|
||||
* 从 RevisitRateLogic::fetchOrderSeqRows 搬出,行为不变(调用方自行分块)。
|
||||
*
|
||||
* @param list<int> $diagnosisIds
|
||||
* @param list<string> $fields 需带 `o.` 别名前缀,如 ['o.id', 'o.diagnosis_id', 'o.create_time']
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function fetchOrderSeqRows(array $diagnosisIds, array $fields): array
|
||||
{
|
||||
if ($diagnosisIds === []) {
|
||||
return [];
|
||||
}
|
||||
$q = Db::name('tcm_prescription_order')
|
||||
->alias('o')
|
||||
->whereIn('o.diagnosis_id', $diagnosisIds)
|
||||
->whereNull('o.delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||
|
||||
return $q
|
||||
->field($fields)
|
||||
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单 => revisit_slot_start_offset(未钳制的原始值;已软删诊单不返回)。
|
||||
* 从 RevisitRateLogic::fetchRevisitSlotStartOffsetMap 搬出,行为不变。
|
||||
*
|
||||
* @param list<int> $diagnosisIds
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public static function fetchRevisitSlotStartOffsetMap(array $diagnosisIds): array
|
||||
{
|
||||
if ($diagnosisIds === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (array_chunk($diagnosisIds, self::CHUNK_SIZE) as $chunk) {
|
||||
$rows = Db::name('tcm_diagnosis')
|
||||
->whereIn('id', $chunk)
|
||||
->whereNull('delete_time')
|
||||
->column('revisit_slot_start_offset', 'id');
|
||||
foreach ($rows as $id => $offset) {
|
||||
$out[(int) $id] = (int) $offset;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移),钳到 0~20。
|
||||
* 从 RevisitRateLogic::resolveRevisitSlotStartOffset 搬出,行为不变。
|
||||
*
|
||||
* @param array<int, int> $map diagnosis_id => offset
|
||||
*/
|
||||
public static function resolveRevisitSlotStartOffset(int $diagnosisId, array $map): int
|
||||
{
|
||||
$offset = (int) ($map[$diagnosisId] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
if ($offset > self::MAX_OFFSET) {
|
||||
$offset = self::MAX_OFFSET;
|
||||
}
|
||||
|
||||
return $offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯函数:时刻 atTs 之前(严格小于)的有效订单数 + 偏移 = 该时刻患者已完成的诊次。
|
||||
* 同一秒的订单视为「指派之后」,不计入(与持有人规则 t <= ct 对偶)。
|
||||
*
|
||||
* @param list<int|string> $orderCreateTimes 有效订单的 create_time 列表(顺序不限)
|
||||
*/
|
||||
public static function slotBefore(array $orderCreateTimes, int $offset, int $atTs): int
|
||||
{
|
||||
$n = 0;
|
||||
foreach ($orderCreateTimes as $ct) {
|
||||
if ((int) $ct < $atTs) {
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
if ($offset > self::MAX_OFFSET) {
|
||||
$offset = self::MAX_OFFSET;
|
||||
}
|
||||
|
||||
return $n + $offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某时刻患者处于第几诊(含偏移)。诊单不存在或无订单时返回偏移值。
|
||||
*/
|
||||
public static function slotAtTime(int $diagnosisId, int $ts): int
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$offsetMap = self::fetchRevisitSlotStartOffsetMap([$diagnosisId]);
|
||||
$offset = self::resolveRevisitSlotStartOffset($diagnosisId, $offsetMap);
|
||||
$rows = self::fetchOrderSeqRows([$diagnosisId], ['o.create_time']);
|
||||
$cts = [];
|
||||
foreach ($rows as $r) {
|
||||
$cts[] = (int) ($r['create_time'] ?? 0);
|
||||
}
|
||||
|
||||
return self::slotBefore($cts, $offset, $ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量计算每个诊单的当前诊次与最新有效订单(每个入参诊单都有返回,无订单时 latest_order 为 null)。
|
||||
*
|
||||
* @param list<int> $diagnosisIds
|
||||
*
|
||||
* @return array<int, array{
|
||||
* diagnosis_id:int, offset:int, valid_order_count:int, current_slot:int, next_slot:int,
|
||||
* first_order_time:int, total_medication_days:int,
|
||||
* latest_order:null|array{id:int, order_no:string, create_time:int, medication_days:int, prescription_id:int, tracking_number:string, fulfillment_status:int|null, creator_id:int}
|
||||
* }>
|
||||
*/
|
||||
public static function batchCurrentSlot(array $diagnosisIds): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($diagnosisIds as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$ids[$id] = true;
|
||||
}
|
||||
}
|
||||
$ids = array_keys($ids);
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$offsetMap = self::fetchRevisitSlotStartOffsetMap($ids);
|
||||
|
||||
/** @var array<int, array{cnt:int, max_ct:int, min_ct:int, sum_days:int}> $agg */
|
||||
$agg = [];
|
||||
/** @var array<int, array<string, mixed>> $latest diagnosis_id => 最新有效订单行 */
|
||||
$latest = [];
|
||||
|
||||
foreach (array_chunk($ids, self::CHUNK_SIZE) as $chunk) {
|
||||
// 1. 聚合:有效订单数 / 最新与最早下单时间 / 累计开药天数
|
||||
// 累计开药天数按「订单 medication_days ?: 处方 usage_days」逐单回落(处方剔除系统代开/作废/软删,一单对一方,不会放大行数)
|
||||
$q = Db::name('tcm_prescription_order')
|
||||
->alias('o')
|
||||
->leftJoin('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.is_system_auto = 0 AND rx.void_status = 0 AND rx.delete_time IS NULL')
|
||||
->whereIn('o.diagnosis_id', $chunk)
|
||||
->whereNull('o.delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||
$aggRows = $q
|
||||
->fieldRaw('o.diagnosis_id, COUNT(*) AS cnt, MAX(o.create_time) AS max_ct, MIN(o.create_time) AS min_ct, SUM(COALESCE(NULLIF(o.medication_days, 0), NULLIF(rx.usage_days, 0), 0)) AS sum_days')
|
||||
->group('o.diagnosis_id')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$maxCts = [];
|
||||
$chunkDiagWithOrders = [];
|
||||
foreach ($aggRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did <= 0) {
|
||||
continue;
|
||||
}
|
||||
$maxCt = (int) ($r['max_ct'] ?? 0);
|
||||
$agg[$did] = [
|
||||
'cnt' => (int) ($r['cnt'] ?? 0),
|
||||
'max_ct' => $maxCt,
|
||||
'min_ct' => (int) ($r['min_ct'] ?? 0),
|
||||
'sum_days' => (int) ($r['sum_days'] ?? 0),
|
||||
];
|
||||
$maxCts[$maxCt] = true;
|
||||
$chunkDiagWithOrders[] = $did;
|
||||
}
|
||||
if ($chunkDiagWithOrders === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 回表:按 (diagnosis_id, create_time) 取候选行,PHP 侧过滤精确匹配并取 id 最大者
|
||||
$q2 = Db::name('tcm_prescription_order')
|
||||
->alias('o')
|
||||
->whereIn('o.diagnosis_id', $chunkDiagWithOrders)
|
||||
->whereIn('o.create_time', array_keys($maxCts))
|
||||
->whereNull('o.delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q2, 'o');
|
||||
$candRows = $q2
|
||||
->field([
|
||||
'o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.medication_days',
|
||||
'o.prescription_id', 'o.tracking_number', 'o.fulfillment_status', 'o.creator_id',
|
||||
])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($candRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did <= 0 || !isset($agg[$did])) {
|
||||
continue;
|
||||
}
|
||||
if ((int) ($r['create_time'] ?? 0) !== $agg[$did]['max_ct']) {
|
||||
continue;
|
||||
}
|
||||
$oid = (int) ($r['id'] ?? 0);
|
||||
if (!isset($latest[$did]) || $oid > (int) ($latest[$did]['id'] ?? 0)) {
|
||||
$latest[$did] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($ids as $did) {
|
||||
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||
$a = $agg[$did] ?? null;
|
||||
$cnt = $a !== null ? $a['cnt'] : 0;
|
||||
$currentSlot = $cnt + $offset;
|
||||
$lo = $latest[$did] ?? null;
|
||||
$latestOrder = null;
|
||||
if ($lo !== null) {
|
||||
$fs = $lo['fulfillment_status'] ?? null;
|
||||
$latestOrder = [
|
||||
'id' => (int) ($lo['id'] ?? 0),
|
||||
'order_no' => (string) ($lo['order_no'] ?? ''),
|
||||
'create_time' => (int) ($lo['create_time'] ?? 0),
|
||||
'medication_days' => (int) ($lo['medication_days'] ?? 0),
|
||||
'prescription_id' => (int) ($lo['prescription_id'] ?? 0),
|
||||
'tracking_number' => trim((string) ($lo['tracking_number'] ?? '')),
|
||||
'fulfillment_status' => $fs === null ? null : (int) $fs,
|
||||
'creator_id' => (int) ($lo['creator_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
$out[$did] = [
|
||||
'diagnosis_id' => $did,
|
||||
'offset' => $offset,
|
||||
'valid_order_count' => $cnt,
|
||||
'current_slot' => $currentSlot,
|
||||
'next_slot' => $currentSlot + 1,
|
||||
'first_order_time' => $a !== null ? $a['min_ct'] : 0,
|
||||
'total_medication_days' => $a !== null ? $a['sum_days'] : 0,
|
||||
'latest_order' => $latestOrder,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// 由 php app/mcp/cli/catalog.php --write 生成,请勿手工修改;人工审核结论写在 resources.php。
|
||||
// 生成时间:2026-09-24 08:54:20
|
||||
// 生成时间:2026-10-14 12:06:18
|
||||
return array (
|
||||
'article.article/add' =>
|
||||
array (
|
||||
@@ -8494,6 +8494,7 @@ return array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
0 => 'kind',
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
@@ -9034,8 +9035,7 @@ return array (
|
||||
'http' => 'POST',
|
||||
'writes' =>
|
||||
array (
|
||||
0 => '->save(',
|
||||
1 => '::create(',
|
||||
0 => '::transaction(',
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
@@ -10063,10 +10063,13 @@ return array (
|
||||
19 => 'latest_appointment_channel_source',
|
||||
20 => 'latest_assign_start_date',
|
||||
21 => 'latest_assign_end_date',
|
||||
22 => 'sort_unserved_days',
|
||||
23 => 'pending_assign_keyword',
|
||||
24 => 'keyword',
|
||||
25 => 'pending_assign_order_month',
|
||||
22 => 'revisit_state',
|
||||
23 => 'revisit_days_bucket',
|
||||
24 => 'sort_revisit_due',
|
||||
25 => 'sort_unserved_days',
|
||||
26 => 'pending_assign_keyword',
|
||||
27 => 'keyword',
|
||||
28 => 'pending_assign_order_month',
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
@@ -10806,6 +10809,238 @@ return array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/apply' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'apply',
|
||||
'kind' => 'write',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/audio' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'audio',
|
||||
'kind' => 'other',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/capabilities' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'capabilities',
|
||||
'kind' => 'other',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/create' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'create',
|
||||
'kind' => 'write',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
0 => '::create(',
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/detail' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'detail',
|
||||
'kind' => 'detail',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/lists' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'lists',
|
||||
'kind' => 'report',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/retry' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'retry',
|
||||
'kind' => 'write',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/saveDraft' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'saveDraft',
|
||||
'kind' => 'write',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/uploadChunk' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'uploadChunk',
|
||||
'kind' => 'write',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/uploadComplete' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'uploadComplete',
|
||||
'kind' => 'write',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.followupAudio/uploadSession' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
|
||||
'action' => 'uploadSession',
|
||||
'kind' => 'write',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.prescription/add' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\PrescriptionController',
|
||||
@@ -12396,6 +12631,202 @@ return array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.revisitDue/close' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
|
||||
'action' => 'close',
|
||||
'kind' => 'write',
|
||||
'lists' => NULL,
|
||||
'http' => 'POST',
|
||||
'writes' =>
|
||||
array (
|
||||
0 => '->update([',
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
0 => 'RevisitDueLogic::close',
|
||||
1 => 'RevisitDueLogic::getError',
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
0 => 'id',
|
||||
1 => 'reason',
|
||||
2 => 'remark',
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.revisitDue/contact' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
|
||||
'action' => 'contact',
|
||||
'kind' => 'other',
|
||||
'lists' => NULL,
|
||||
'http' => 'POST',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
0 => 'RevisitDueLogic::contact',
|
||||
1 => 'RevisitDueLogic::getError',
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
0 => 'id',
|
||||
1 => 'promised_date',
|
||||
2 => 'remark',
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.revisitDue/lists' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
|
||||
'action' => 'lists',
|
||||
'kind' => 'list',
|
||||
'lists' => 'app\\adminapi\\lists\\tcm\\RevisitDueLists',
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
0 => 'assistant_id',
|
||||
1 => 'dept_id',
|
||||
2 => 'keyword',
|
||||
3 => 'min_snooze',
|
||||
4 => 'state',
|
||||
5 => 'days_bucket',
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.revisitDue/logs' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
|
||||
'action' => 'logs',
|
||||
'kind' => 'report',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
0 => 'RevisitDueLogic::logs',
|
||||
1 => 'RevisitDueLogic::getError',
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
0 => 'due_id',
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.revisitDue/pending' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
|
||||
'action' => 'pending',
|
||||
'kind' => 'other',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
0 => 'RevisitDueLogic::pending',
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.revisitDue/snooze' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
|
||||
'action' => 'snooze',
|
||||
'kind' => 'other',
|
||||
'lists' => NULL,
|
||||
'http' => 'POST',
|
||||
'writes' =>
|
||||
array (
|
||||
0 => '->update([',
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
0 => 'RevisitDueLogic::snooze',
|
||||
1 => 'RevisitDueLogic::getError',
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
0 => 'id',
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.revisitDue/snoozeAll' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
|
||||
'action' => 'snoozeAll',
|
||||
'kind' => 'other',
|
||||
'lists' => NULL,
|
||||
'http' => 'POST',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
0 => 'RevisitDueLogic::snoozeAll',
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
0 => 'ids',
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tcm.revisitDue/summary' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
|
||||
'action' => 'summary',
|
||||
'kind' => 'report',
|
||||
'lists' => NULL,
|
||||
'http' => 'GET',
|
||||
'writes' =>
|
||||
array (
|
||||
),
|
||||
'external' =>
|
||||
array (
|
||||
),
|
||||
'logic' =>
|
||||
array (
|
||||
0 => 'RevisitDueLogic::summary',
|
||||
),
|
||||
'params' =>
|
||||
array (
|
||||
),
|
||||
'no_login' => false,
|
||||
),
|
||||
'tools.generator/dataTable' =>
|
||||
array (
|
||||
'controller' => 'app\\adminapi\\controller\\tools\\GeneratorController',
|
||||
|
||||
@@ -18,8 +18,12 @@
|
||||
* 子接口自身已登记时仍按自身判断(与后台一致);未登记时依次改用 Tab 权限(tabLeaderboard/tabDoctor/tabZyyt…)
|
||||
* 或页面权限 fans/yeji ——后台对未登记接口不校验、只靠这些权限控制页面可见,这样不会比后台页面更宽。
|
||||
* - timeout:业绩看板、医生统计、提成结算、综合转化是多张大表的聚合(后台控制器自己放宽到 120 秒),单条 SQL 超时放宽到 30 秒。
|
||||
* - controller:晚于 generated.php 生成的 action(如 stats.assistantPerformance/revisitRate)显式写 controller 以通过 AiMcpUnitTest 的
|
||||
* 资源存在性校验;重跑 catalog.php --write 后可去掉该键。
|
||||
*/
|
||||
|
||||
use app\adminapi\controller\stats\AssistantPerformanceController;
|
||||
|
||||
$dateRange = [
|
||||
'start_date' => '开始日期 YYYY-MM-DD(不传默认今天)',
|
||||
'end_date' => '结束日期 YYYY-MM-DD(不传同开始日期)',
|
||||
@@ -43,9 +47,18 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'stats.assistantPerformance/revisitRate' => [
|
||||
'status' => 'open', 'kind' => 'report', 'name' => '医助个人业绩 · 我的复诊接诊率',
|
||||
'controller' => AssistantPerformanceController::class, 'http' => 'GET',
|
||||
'note' => '仅返回当前账号本人的复诊接诊率行(RevisitRateLogic::assistantOverview:从 stats.revisitRate/overview 全量结果中按本人 assistant_id 挑出一行,不含其他医助、不含部门/合计行),口径与复诊接诊率页一致(inherit_mode 固定取配置 project.revisit_rate.default_inherit_mode,默认 slot=继承患者按继承时诊次从第 k+1 诊起计入分母)。row 字段:assigned_count 新指派数、inherit_count 继承数、assigned_total_count 被指派合计、visit{N}_count/_den/_rate 各诊次单数/分母/接诊率(den=0 为 null)、visit_all_count/_den/_rate 复诊合计(可超 100%);slots 为诊次列范围。本人非二中心医助(in_scope=false)或当月无任何被指派/成交(in_scope=true)时 row=null。结果缓存 600 秒。',
|
||||
'params_allow' => [
|
||||
'month' => '统计月份 YYYY-MM(默认本月)',
|
||||
],
|
||||
],
|
||||
|
||||
'stats.autoAssignLog/lists' => [
|
||||
'status' => 'open', 'kind' => 'list', 'name' => '待分配诊单自动指派日志',
|
||||
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到全部自动指派日志(含患者姓名、手机号快照)。数据由定时任务 tcm:auto-assign-pending 写入,每条待指派诊单一行;action 1=已分配、0=未分配,tier 为医助上月二诊复诊接诊率档位(gt70/60_70/50_60),reason 为分配或不分配原因。',
|
||||
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到全部自动指派日志(含患者姓名、手机号快照)。数据由定时任务 tcm:auto-assign-pending 写入,每条待指派诊单一行;action 1=已分配、0=未分配,tier 为医助上月二诊复诊接诊率档位(gt70/60_70/50_60),reason 为分配或不分配原因。口径:visit2_rate=新口径(inherit_mode=slot,分母=新指派+起算诊次≤2 的继承)、visit2_rate_new_assign=旧口径(分母仅新指派)、rate_version=本批次分档所用口径(v1=旧 v2=新,由配置 project.revisit_rate.auto_assign_tier_field 决定;2026-10 前的历史行无 visit2_rate_new_assign、rate_version=v1 且 visit2_rate 即旧口径)。',
|
||||
'params_allow' => [
|
||||
'run_date' => '执行日期 YYYY-MM-DD(精确匹配)',
|
||||
'start_date' => '执行日期起 YYYY-MM-DD',
|
||||
@@ -172,39 +185,43 @@ return [
|
||||
|
||||
'stats.revisitRate/assignLines' => [
|
||||
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · 被指派明细',
|
||||
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助当月被指派的诊单(含患者姓名、手机号)。口径:按指派操作时间落月、非继承指派、医助×诊单去重,剔除名下有拒收/退款订单的诊单;仅统计二中心及其下级部门。不传 assistant_id/dept_id 时返回全部。',
|
||||
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助当月被指派的诊单(含患者姓名、手机号)。口径:按指派操作时间落月、医助×诊单去重,剔除名下有拒收/退款订单的诊单;仅统计二中心及其下级部门。行字段 assign_type=new(新指派,is_inherit=0)/inherit(继承,is_inherit=1,含历史回退自动分配行),inherit_visit_slot=继承时患者已完成诊次 k,entry_slot=起算诊次(max(2,k+1);新指派恒为 2)。部门/合计范围下诊单对任一医助为新指派即记 new,否则记 inherit 且 entry 取最小,故 kind=all 的「new 行数 + entry<=N 的 inherit 行数」= 复诊接诊率 visit{N}_den。inherit_mode=exclude 时只有 new 行(旧口径)。不传 assistant_id/dept_id 时返回全部。',
|
||||
'params_allow' => [
|
||||
'month' => '统计月份 YYYY-MM(默认本月)',
|
||||
'dept_ids' => '部门筛选(限二中心子树),多个逗号分隔',
|
||||
'assistant_id' => '只看某医助',
|
||||
'dept_id' => '只看某部门分组(0=未分配部门)',
|
||||
'inherit_mode' => '口径:slot=继承按继承时诊次计入分母(默认,配置 project.revisit_rate.default_inherit_mode)/ exclude=继承不计分母(旧口径,与 2026-10 前一致)',
|
||||
'kind' => '行类型:new 仅新指派 / inherit 仅继承 / all 两者(默认 all)',
|
||||
],
|
||||
],
|
||||
|
||||
'stats.revisitRate/deptOptions' => [
|
||||
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · 部门选项',
|
||||
'note' => '二中心及其下级部门(id、pid、name),供 dept_ids 参数取值。',
|
||||
'note' => '二中心及其下级部门(id、pid、name),供 revisitRate 各接口 dept_ids 参数取值;不受 inherit_mode 影响。',
|
||||
'params_allow' => [],
|
||||
],
|
||||
|
||||
'stats.revisitRate/overview' => [
|
||||
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率',
|
||||
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助的数据。口径:当月被指派数=当月非继承指派的医助×诊单(剔除名下有拒收/退款订单的诊单);N 诊单数=当月下单且为该诊单全局第 N 笔计入业绩的业务订单(剔除取消/拒收/退款,诊次跨月累计),归属下单时的持有医助;N 诊接诊率=N 诊单数÷当月被指派数(往月指派当月成交会使比率超过 100%)。按部门→医助分组并有合计行。',
|
||||
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助的数据。口径 v2(每列独立分母):assigned_count=新指派数(当月 is_inherit=0 的医助×诊单,剔除名下有拒收/退款订单的诊单,语义与旧版「当月被指派数」相同);inherit_count=继承数(当月 is_inherit=1、from≠to,同诊单多次继承取最早,既新指派又继承只算新指派;含历史回退自动分配行);assigned_total_count=两者并集;visit{N}_count=当月下单且为该诊单全局第 N 笔计入业绩的业务订单(剔除取消/拒收/退款,诊次跨月累计),归属下单时的持有医助;visit{N}_den=新指派数+起算诊次≤N 的继承数(起算诊次=继承时已完成诊次+1,最小 2);visit{N}_rate=count÷den(den=0 为 null;往月指派当月成交会使比率超过 100%);visit_all_count=Σ visit{N}_count、visit_all_den=assigned_total_count、visit_all_rate=复诊合计接诊率(可超 100%);visit2_rate_new_assign=旧口径二诊率(分母仅新指派,自动分配过渡期分档用)。slots 为列范围(2~max(4,分子最大诊次,最大起算诊次),上限 50)。inherit_mode=exclude 复现旧口径:inherit_count=0、visit{N}_den=assigned_count,assigned_count/visit{N}_count/visit{N}_rate 与 2026-10 前逐值一致。继承分母依赖 is_inherit 列,2026-06 之前的继承无法回溯。按部门→医助分组并有合计行(部门/合计按诊单去重,继承起算诊次取部门内最小)。',
|
||||
'params_allow' => [
|
||||
'month' => '统计月份 YYYY-MM(默认本月)',
|
||||
'dept_ids' => '部门筛选(限二中心子树,含下级),多个逗号分隔',
|
||||
'inherit_mode' => '口径:slot=继承按继承时诊次计入每列独立分母(默认,配置 project.revisit_rate.default_inherit_mode)/ exclude=继承不计分母(旧口径)',
|
||||
],
|
||||
],
|
||||
|
||||
'stats.revisitRate/visitOrderLines' => [
|
||||
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · N 诊订单明细',
|
||||
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助的 N 诊订单(含订单号、金额、患者姓名、手机号)。与复诊接诊率“N 诊单数”同口径,可对账。',
|
||||
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助的 N 诊订单(含订单号、金额、患者姓名、手机号)。与复诊接诊率“N 诊单数”(visit{N}_count) 同口径,可对账;slot=0 返回全部 N>=2 的订单,与「复诊合计单数」(visit_all_count) 对账,行字段 visit_slot 标明诊次。分子不受 inherit_mode 影响。',
|
||||
'params_allow' => [
|
||||
'month' => '统计月份 YYYY-MM(默认本月)',
|
||||
'slot' => '诊次 N(必填,2=二诊,最大 50)',
|
||||
'slot' => '诊次 N(必填,2=二诊,最大 50;0=全部 N>=2 即复诊合计)',
|
||||
'dept_ids' => '部门筛选(限二中心子树),多个逗号分隔',
|
||||
'assistant_id' => '只看某医助',
|
||||
'dept_id' => '只看某部门分组(0=未分配部门)',
|
||||
'inherit_mode' => '口径 slot|exclude(仅影响医助范围判定,不影响订单归属)',
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* AI 数据目录人工审核:中医诊单与处方(tcm.*)的全部非写、非 POST 接口(51 个)。
|
||||
* 例外:复诊到期弹窗 tcm.revisitDue/* 的写接口也显式登记为 excluded(控制器晚于 generated.php 生成,需显式 controller 过单测)。
|
||||
*
|
||||
* 约定:
|
||||
* - 按诊单/患者/记录ID取数的接口一律按「详情」调用(kind=detail):zyt_get 会把 ID 固定为单个值,
|
||||
@@ -10,6 +11,7 @@
|
||||
* - 'builtin' 表示接口自身已有逐条校验,函数写在各条目上方注释里。
|
||||
*/
|
||||
|
||||
use app\adminapi\controller\tcm\RevisitDueController;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||
|
||||
@@ -134,6 +136,37 @@ return [
|
||||
'tcm.diagnosisTodo/detail' => ['status' => 'pending', 'name' => '诊单待办详情',
|
||||
'reason' => '按待办ID读取任意诊单的待办,无逐条权限校验(DiagnosisTodoLogic::detail);可改用 tcm.diagnosisTodo/lists(按诊单ID,已校验诊单可见)'],
|
||||
|
||||
// ───────────── 复诊到期提醒(需求 4 右下角弹窗 + P2 主管团队视图,controller/tcm/RevisitDueController.php) ─────────────
|
||||
// 控制器晚于 generated.php 生成,显式写 controller 以通过 AiMcpUnitTest 的资源存在性校验;重跑 catalog.php --write 后可去掉该键。
|
||||
// pending 是轮询端点且「读取即写」(first_shown_at/last_shown_at + 首次弹出流水,RevisitDueLogic::markShown),只读事务下会报错回滚,
|
||||
// 又含患者姓名快照,不对 AI 开放;按医助查询到期患者请走 P2 的 tcm.revisitDue/lists(主管视图,HasDataScopeFilter,待评审后开放)。
|
||||
'tcm.revisitDue/pending' => ['status' => 'excluded', 'name' => '复诊到期弹窗-待办', 'controller' => RevisitDueController::class, 'http' => 'GET',
|
||||
'reason' => '右下角弹窗轮询接口:读取即更新弹出时间并写「首次弹出」流水(只读事务下回滚),且返回患者姓名;到期患者查询请用 tcm.revisitDue/lists(P2 主管视图,待评审开放)'],
|
||||
'tcm.revisitDue/summary' => ['status' => 'pending', 'name' => '复诊到期弹窗-计数', 'controller' => RevisitDueController::class, 'http' => 'GET', 'kind' => 'report',
|
||||
'reason' => '仅返回当前登录医助本人的到期 / 预警计数(RevisitDueLogic::summary,不写库、无患者信息),供弹窗气泡;待 P2 主管视图 tcm.revisitDue/lists 评审口径后一并开放'],
|
||||
// P2 主管团队视图:lists 为快照表(tcm_diagnosis_revisit_due)分页列表,RevisitDueLists 按 HasDataScopeFilter::applyDataScopeByOwner('assistant_id') 收窄,
|
||||
// 不写库(无自愈),但逐行返回患者姓名快照与医助延迟行为(snooze_count/escalation_level/人工关闭原因),且 state 由 CURDATE() 推导、
|
||||
// 菜单 08_add_revisit_due_team_menu.sql 默认不授予任何角色;待产品验收口径与授权范围后再开放。
|
||||
'tcm.revisitDue/lists' => ['status' => 'pending', 'name' => '复诊到期跟进(主管团队视图)', 'controller' => RevisitDueController::class, 'http' => 'GET', 'kind' => 'list',
|
||||
'params_allow' => [
|
||||
'assistant_id' => '医助(后台账号)ID', 'dept_id' => '医助所属部门ID(含下级部门)',
|
||||
'state' => '状态 due 到期未挂号/过号 | warning 预警 | booked 已约 | visited 已面诊 | contacted 已联系 | no_days 缺开药天数 | pending 未到期 | closed 人工关闭',
|
||||
'days_bucket' => '开药天数档位 14|21|28|30|other', 'min_snooze' => '延迟次数下限', 'keyword' => '患者姓名(模糊)',
|
||||
],
|
||||
'forbid' => ['export', 'file_name', 'page_start', 'page_end'],
|
||||
'reason' => '逐行返回患者姓名快照与医助「延迟/人工关闭」行为数据(越点越上心的考核依据),数据范围虽按角色 data_scope 收窄,但主管视图菜单(08_add_revisit_due_team_menu.sql)默认不授予任何角色、口径(CURDATE() 推导状态 / 人工关闭标记)尚待产品验收;验收后改 open'],
|
||||
'tcm.revisitDue/logs' => ['status' => 'pending', 'name' => '复诊到期跟进-事件流水', 'controller' => RevisitDueController::class, 'http' => 'GET', 'kind' => 'detail', 'id_param' => 'due_id',
|
||||
'params_allow' => [],
|
||||
'reason' => '按提醒 id(due_id)返回延迟/已联系/人工关闭/企微推送等流水,含患者姓名与人工关闭说明(自由文本可能含手机号等);逐条校验(RevisitDueLogic::canViewRow:本人 / 超管 / DataScope 可见医助)已内建,待 tcm.revisitDue/lists 开放后一并评审;开放时可写 guard=builtin'],
|
||||
'tcm.revisitDue/snooze' => ['status' => 'excluded', 'name' => '复诊到期弹窗-延迟', 'controller' => RevisitDueController::class, 'http' => 'POST', 'kind' => 'write',
|
||||
'reason' => '写接口:延迟提醒(更新 snooze_count/next_remind_at 并写流水),AI 只读'],
|
||||
'tcm.revisitDue/snoozeAll' => ['status' => 'excluded', 'name' => '复诊到期弹窗-全部延迟', 'controller' => RevisitDueController::class, 'http' => 'POST', 'kind' => 'write',
|
||||
'reason' => '写接口:批量延迟提醒,AI 只读'],
|
||||
'tcm.revisitDue/contact' => ['status' => 'excluded', 'name' => '复诊到期弹窗-已联系', 'controller' => RevisitDueController::class, 'http' => 'POST', 'kind' => 'write',
|
||||
'reason' => '写接口:记录患者承诺复诊日(更新 promised_date/contact_count 并写流水),AI 只读'],
|
||||
'tcm.revisitDue/close' => ['status' => 'excluded', 'name' => '复诊到期弹窗-人工关闭', 'controller' => RevisitDueController::class, 'http' => 'POST', 'kind' => 'write',
|
||||
'reason' => '写接口:人工关闭提醒周期(closed_state=4 并写流水),AI 只读'],
|
||||
|
||||
// ───────────── 处方 ─────────────
|
||||
'tcm.prescription/lists' => ['status' => 'open', 'kind' => 'list',
|
||||
'params_allow' => ['patient_name' => '患者姓名(模糊)', 'sn' => '处方编号(模糊)',
|
||||
|
||||
Reference in New Issue
Block a user