Files
zyt/server/app/adminapi/lists/tcm/RevisitDueLists.php
T
2026-10-15 11:03:54 +08:00

382 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\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;
}
}