*/ 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 $cfg * * @return array */ 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, * first_touch_ts?:int|null, total_medication_days?:int, diagnosis_active?:bool * } $input * @param array $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 $row 快照行(含 closed_state/appointment_state/appointment_date/due_date/promised_date/valid_order_count/source_order_id/assistant_id) * @param array $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) : ''; } }