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 $row * @param array $cfg * @param array $nameMap * @param array $deptMap * * @return array */ 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 $ids * * @return array */ 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 $ids * * @return array */ 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; } }