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
@@ -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;
}
}