This commit is contained in:
gr
2026-10-15 11:03:54 +08:00
parent 70ddc1a70a
commit bb28d93cec
64 changed files with 12747 additions and 420 deletions
@@ -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;
}
}