0, 'escalation_level' => 0, 'promised_date' => null, 'contact_count' => 0, 'contacted_at' => 0, 'missed_count' => 0, 'next_remind_at' => 0, 'first_shown_at' => 0, 'last_shown_at' => 0, 'last_snoozed_at' => 0, 'last_snoozed_by' => 0, 'handled_at' => 0, 'handled_by' => 0, 'handle_reason' => '', 'handle_remark' => '', 'wecom_self_at' => 0, 'wecom_self_count_day' => 0, 'wecom_supervisor_at' => 0, ]; /** 换医助时重置的字段(新医助不背前任的账;人工关闭记录保留) */ private const RESET_FIELDS_ASSISTANT = [ 'snooze_count' => 0, 'escalation_level' => 0, 'promised_date' => null, 'contact_count' => 0, 'contacted_at' => 0, 'missed_count' => 0, 'first_shown_at' => 0, 'last_shown_at' => 0, 'last_snoozed_at' => 0, 'last_snoozed_by' => 0, 'wecom_self_at' => 0, 'wecom_self_count_day' => 0, 'wecom_supervisor_at' => 0, ]; /** * 读取 revisit_reach 配置并归一(缺项补默认值)。 * * @return array */ public static function config(): array { $raw = config('project.revisit_reach'); return RevisitDueCalculator::normalizeConfig(is_array($raw) ? $raw : []); } /** * 批量刷新诊单快照。 * * @param list $diagnosisIds * @param array $ctx operator_id(操作人,0=系统)、reason(写入流水 payload)、dry_run(true 只计算不写库,返回 rows)、 * now(基准时间戳)、today('Y-m-d' 基准日,默认由 now 推导) * * @return array{refreshed:int, inserted:int, closed:int, reopened:int, no_days:int, rows:list>} */ public static function refreshDiagnoses(array $diagnosisIds, array $ctx = []): array { $result = ['refreshed' => 0, 'inserted' => 0, 'closed' => 0, 'reopened' => 0, 'no_days' => 0, 'rows' => []]; $ids = self::normalizeIds($diagnosisIds); if ($ids === []) { return $result; } $cfg = self::config(); $now = (int) ($ctx['now'] ?? 0); if ($now <= 0) { $now = time(); } $today = (string) ($ctx['today'] ?? ''); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $today) !== 1) { $today = date('Y-m-d', $now); } $operatorId = (int) ($ctx['operator_id'] ?? 0); $reason = mb_substr(trim((string) ($ctx['reason'] ?? '')), 0, 64); $dryRun = (bool) ($ctx['dry_run'] ?? false); foreach (array_chunk($ids, self::REFRESH_CHUNK) as $chunk) { $inputs = self::collectInputs($chunk, $cfg); $existing = self::fetchSnapshotRows($chunk); foreach ($chunk as $did) { $input = $inputs[$did] ?? null; if ($input === null) { continue; } $facts = RevisitDueCalculator::computeFacts($input, $cfg, $today); $old = $existing[$did] ?? null; [$row, $events, $flags] = self::planUpsert($did, $facts, $input, $old, $now, $reason); if ($facts['closed_state_auto'] !== RevisitDueCalculator::CLOSED_INVALID && $facts['medication_days'] <= 0) { $result['no_days']++; } if ($flags['closed']) { $result['closed']++; } if ($flags['reopened']) { $result['reopened']++; } if ($dryRun) { $preview = $row; $preview['_events'] = array_map(static fn (array $e): int => (int) $e['event'], $events); $preview['_is_new'] = $old === null; $result['rows'][] = $preview; $result['refreshed']++; continue; } // 非新周期时保护并发写入的人工关闭(读旧行到写回之间医助可能已 close) self::upsertRow($row, $now, !$flags['new_cycle']); $result['refreshed']++; if ($old === null) { $result['inserted']++; } if ($events !== []) { $dueId = (int) ($old['id'] ?? 0); if ($dueId <= 0) { $dueId = (int) Db::name(self::TABLE)->where('diagnosis_id', $did)->value('id'); } foreach ($events as $e) { self::writeLog( $dueId, $did, (int) $row['assistant_id'], (int) $row['source_order_id'], (int) $e['event'], [ 'snooze_count_after' => (int) ($row['snooze_count'] ?? ($old['snooze_count'] ?? 0)), 'level_after' => (int) ($row['escalation_level'] ?? ($old['escalation_level'] ?? 0)), 'overdue_days' => $row['due_date'] !== null ? RevisitDueCalculator::dayDiff((string) $row['due_date'], $today) : 0, 'payload' => $e['payload'], 'operator_id' => $operatorId, 'create_time' => $now, ] ); } } } } return $result; } /** * closed_state=2(已挂号待面诊)或 appointment_state=1 的行重算:挂号被取消 / 过号 / 完成后推进或重开。 * * @return int 重算的诊单数 */ public static function reevaluateBookedRows(): int { $ids = Db::name(self::TABLE) ->where(static function ($q): void { $q->where('closed_state', RevisitDueCalculator::CLOSED_BOOKED) ->whereOr('appointment_state', RevisitDueCalculator::APPT_BOOKED); }) ->column('diagnosis_id'); $ids = self::normalizeIds($ids); if ($ids === []) { return 0; } foreach (array_chunk($ids, self::REFRESH_CHUNK) as $chunk) { self::refreshDiagnoses($chunk, ['reason' => 'reevaluate_booked']); } return \count($ids); } /** * 候选诊单。 * * - full:tcm_diagnosis.delete_time IS NULL AND status=1 AND CAST(assistant_id AS UNSIGNED)>0; * limit_er_center 时再限「二中心」子树医助(DeptLogic::getErCenterSubtreeDeptIdSet + admin_dept); * - incremental:自 sinceTs 以来 tcm_prescription_order.update_time / doctor_appointment.update_time / * tcm_diagnosis_assign_log.create_time / tcm_diagnosis.update_time 有变化的诊单(保留有医助或已有快照的), * ∪ closed_state=2 的行。 * * @return list */ public static function candidateDiagnosisIds(string $mode, int $sinceTs = 0): array { $cfg = self::config(); if ($mode === 'full') { $q = Db::name('tcm_diagnosis') ->whereNull('delete_time') ->where('status', 1) ->whereRaw('CAST(assistant_id AS UNSIGNED) > 0'); if ($cfg['limit_er_center']) { $deptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); if ($deptSet === []) { return []; } $adminIds = Db::name('admin_dept') ->whereIn('dept_id', array_keys($deptSet)) ->distinct(true) ->column('admin_id'); $adminIds = self::normalizeIds($adminIds); if ($adminIds === []) { return []; } $q->whereIn('assistant_id', $adminIds); } $ids = self::normalizeIds($q->order('id', 'asc')->column('id')); // 并入仍未失效的既有快照行:诊单被删除/禁用/释放医助后不再满足候选条件, // 需要由全量重算把它们推进到 closed_state=5(refresh 按 facts 自动置 5) $openRows = self::normalizeIds( Db::name(self::TABLE) ->where('closed_state', '<>', RevisitDueCalculator::CLOSED_INVALID) ->column('diagnosis_id') ); if ($openRows !== []) { $ids = array_values(array_unique(array_merge($ids, $openRows))); sort($ids); } return $ids; } // incremental $since = $sinceTs > 0 ? $sinceTs : time() - 7200; $set = []; $collect = static function (array $list) use (&$set): void { foreach ($list as $v) { $v = (int) $v; if ($v > 0) { $set[$v] = true; } } }; $collect(Db::name('tcm_prescription_order') ->where('update_time', '>=', $since) ->where('diagnosis_id', '>', 0) ->distinct(true) ->column('diagnosis_id')); $collect(Db::name('doctor_appointment') ->where('update_time', '>=', $since) ->where('patient_id', '>', 0) ->distinct(true) ->column('patient_id')); $collect(Db::name('tcm_diagnosis_assign_log') ->where('create_time', '>=', $since) ->where('diagnosis_id', '>', 0) ->distinct(true) ->column('diagnosis_id')); // 不排除已软删诊单:删除后其快照行需推进到 closed_state=5(下方「已有快照行」分支保留它) $collect(Db::name('tcm_diagnosis') ->where('update_time', '>=', $since) ->column('id')); // 范围收敛:只保留「当前有医助」或「已有快照行」的诊单,避免一中心/未指派诊单的订单变动触发无意义重算 $changed = array_keys($set); $kept = []; foreach (array_chunk($changed, 2000) as $chunk) { $withAssistant = Db::name('tcm_diagnosis') ->whereIn('id', $chunk) ->whereNull('delete_time') ->whereRaw('CAST(assistant_id AS UNSIGNED) > 0') ->column('id'); foreach ($withAssistant as $id) { $kept[(int) $id] = true; } $withRow = Db::name(self::TABLE)->whereIn('diagnosis_id', $chunk)->column('diagnosis_id'); foreach ($withRow as $id) { $kept[(int) $id] = true; } } $booked = Db::name(self::TABLE)->where('closed_state', RevisitDueCalculator::CLOSED_BOOKED)->column('diagnosis_id'); foreach ($booked as $id) { $kept[(int) $id] = true; } unset($kept[0]); $out = array_keys($kept); sort($out); return $out; } /** * 列表行 revisit_due 对象(契约 C)。 * * 自愈:self_heal 为 true 且 page_size<=100 时,对本页前 50 行中「快照缺行(且有有效订单)」或 * 「source_order_id / current_slot 与 batchCurrentSlot 不一致」的诊单先 refreshDiagnoses 再读;其余交给增量 cron。 * * @param list $diagnosisIds 保持页面顺序 * @param array $opts self_heal(bool, 默认 true)、page_size(int)、today('Y-m-d') * * @return array> diagnosis_id => revisit_due 对象 */ public static function buildListPayload(array $diagnosisIds, array $opts = []): array { $ids = self::normalizeIds($diagnosisIds); if ($ids === []) { return []; } $cfg = self::config(); $today = (string) ($opts['today'] ?? ''); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $today) !== 1) { $today = date('Y-m-d'); } $selfHeal = (bool) ($opts['self_heal'] ?? true); $pageSize = (int) ($opts['page_size'] ?? 0); try { $rows = self::fetchSnapshotRows($ids); } catch (\Throwable $e) { // 快照表缺失/库异常不得拖垮诊单列表:全部按 none 占位 Log::warning('复诊到期列表读取快照失败: ' . $e->getMessage()); $out = []; foreach ($ids as $did) { $out[$did] = self::emptyPayload(); } return $out; } if ($selfHeal && $pageSize <= self::SELF_HEAL_MAX_PAGE_SIZE) { try { $cands = array_slice($ids, 0, self::SELF_HEAL_MAX_ROWS); $slotMap = RevisitVisitSlotService::batchCurrentSlot($cands); // 当前持有医助:无快照且无医助的诊单(待分配 Tab 等)不建行,避免热路径写库 $assistantMap = Db::name('tcm_diagnosis') ->whereIn('id', $cands) ->column('CAST(assistant_id AS UNSIGNED)', 'id'); $toRefresh = []; foreach ($cands as $did) { $s = $slotMap[$did] ?? null; if ($s === null) { continue; } $latestId = (int) ($s['latest_order']['id'] ?? 0); $row = $rows[$did] ?? null; if ($row === null) { // 无快照:仅当有有效订单且当前有医助时才建行,否则直接按 none 展示 if ($latestId > 0 && (int) ($assistantMap[$did] ?? 0) > 0) { $toRefresh[] = $did; } continue; } if ((int) $row['source_order_id'] !== $latestId || (int) $row['current_slot'] !== (int) $s['current_slot']) { $toRefresh[] = $did; } } if ($toRefresh !== []) { self::refreshDiagnoses($toRefresh, ['reason' => 'list_self_heal', 'today' => $today]); $rows = array_replace($rows, self::fetchSnapshotRows($toRefresh)); } } catch (\Throwable $e) { Log::warning('复诊到期列表自愈失败: ' . $e->getMessage()); } } $out = []; foreach ($ids as $did) { $row = $rows[$did] ?? null; $out[$did] = $row !== null ? self::formatPayload($row, $today, $cfg) : self::emptyPayload(); } return $out; } /** * 快照行 → 契约 C 对象。 * * @param array $row 快照表一行 * @param array $cfg revisit_reach 配置(已归一) * * @return array */ public static function formatPayload(array $row, string $today, array $cfg): array { $st = RevisitDueCalculator::deriveState($row, $today, $cfg); $anchorTs = (int) ($row['anchor_ts'] ?? 0); $orderCt = (int) ($row['order_create_time'] ?? 0); $firstTouch = self::dateStr($row['first_touch_date'] ?? null); return [ 'current_slot' => (int) ($row['current_slot'] ?? 0), 'next_slot' => (int) ($row['next_slot'] ?? 0), 'valid_order_count' => (int) ($row['valid_order_count'] ?? 0), 'medication_days' => (int) ($row['medication_days'] ?? 0), 'days_src' => (int) ($row['days_src'] ?? 0), 'rule_days' => (int) ($row['rule_days'] ?? 0), 'anchor_src' => (int) ($row['anchor_src'] ?? 0), 'anchor_date' => $anchorTs > 0 ? date('Y-m-d', $anchorTs) : '', 'order_date' => $orderCt > 0 ? date('Y-m-d', $orderCt) : '', 'due_date' => self::dateStr($row['due_date'] ?? null), 'days_left' => $st['days_left'], 'overdue_days' => $st['overdue_days'], '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), 'promised_date' => self::dateStr($row['promised_date'] ?? null), 'missed_count' => (int) ($row['missed_count'] ?? 0), 'first_touch_date' => $firstTouch, 'in_care_days' => $firstTouch !== '' ? RevisitDueCalculator::dayDiff($firstTouch, $today) : null, 'total_medication_days' => (int) ($row['total_medication_days'] ?? 0), 'closed_state' => (int) ($row['closed_state'] ?? 0), ]; } /** * 无快照且无法计算时的占位对象(state='none',前端显示「—」)。 * * @return array */ public static function emptyPayload(): array { return [ 'current_slot' => 0, 'next_slot' => 0, 'valid_order_count' => 0, 'medication_days' => 0, 'days_src' => 0, 'rule_days' => 0, 'anchor_src' => 0, 'anchor_date' => '', 'order_date' => '', 'due_date' => '', 'days_left' => null, 'overdue_days' => null, 'state' => RevisitDueCalculator::STATE_NONE, 'state_text' => '', 'severe' => 0, 'appointment_state' => 0, 'appointment_date' => '', 'snooze_count' => 0, 'escalation_level' => 0, 'promised_date' => '', 'missed_count' => 0, 'first_touch_date' => '', 'in_care_days' => null, 'total_medication_days' => 0, 'closed_state' => 0, ]; } /** * 写事件流水(失败只记 warning,绝不影响主流程)。 * * @param array $extra snooze_count_after / level_after / overdue_days / payload(array|string) / operator_id / create_time */ public static function writeLog(int $dueId, int $diagnosisId, int $assistantId, int $sourceOrderId, int $event, array $extra = []): void { try { $payload = $extra['payload'] ?? ''; if (is_array($payload)) { $payload = $payload === [] ? '' : (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $payload = mb_substr((string) $payload, 0, 500); $createTime = (int) ($extra['create_time'] ?? 0); if ($createTime <= 0) { $createTime = time(); } $overdue = (int) ($extra['overdue_days'] ?? 0); $overdue = max(-32768, min(32767, $overdue)); Db::name(self::LOG_TABLE)->insert([ 'due_id' => max(0, $dueId), 'diagnosis_id' => max(0, $diagnosisId), 'assistant_id' => max(0, $assistantId), 'source_order_id' => max(0, $sourceOrderId), 'event' => max(0, $event), 'snooze_count_after' => max(0, (int) ($extra['snooze_count_after'] ?? 0)), 'level_after' => max(0, (int) ($extra['level_after'] ?? 0)), 'overdue_days' => $overdue, 'payload' => $payload, 'operator_id' => max(0, (int) ($extra['operator_id'] ?? 0)), 'create_time' => $createTime, ]); } catch (\Throwable $e) { Log::warning('复诊到期流水写入失败 diagnosis_id=' . $diagnosisId . ' event=' . $event . ' msg=' . $e->getMessage()); } } // ---------------------------------------------------------------- 内部:取数 /** * 批量组装 Calculator 输入。 * * @param list $chunk * @param array $cfg * * @return array> diagnosis_id => input */ private static function collectInputs(array $chunk, array $cfg): array { // 诊单基础信息(含已删除/禁用,用于判失效) $diagRows = []; foreach (Db::name('tcm_diagnosis') ->whereIn('id', $chunk) ->field(['id', 'assistant_id', 'patient_name', 'status', 'delete_time']) ->select() ->toArray() as $r) { $diagRows[(int) $r['id']] = $r; } $slotMap = RevisitVisitSlotService::batchCurrentSlot($chunk); $orderIds = []; $rxIds = []; $signItems = []; $orderDateByDiag = []; foreach ($slotMap as $did => $s) { $lo = $s['latest_order'] ?? null; if ($lo === null) { continue; } $oid = (int) $lo['id']; $orderIds[] = $oid; if ((int) $lo['prescription_id'] > 0) { $rxIds[] = (int) $lo['prescription_id']; } $signItems[] = ['order_id' => $oid, 'tracking_number' => (string) $lo['tracking_number']]; if ((int) $lo['create_time'] > 0) { $orderDateByDiag[(int) $did] = date('Y-m-d', (int) $lo['create_time']); } } $chain = (array) ($cfg['anchor_chain'] ?? []); $rxDaysMap = ($cfg['days_fallback_rx'] && $rxIds !== []) ? self::fetchRxUsageDaysMap($rxIds) : []; $signMap = (in_array('sign', $chain, true) && $signItems !== []) ? self::fetchSignTsMap($signItems) : []; $shipMap = (in_array('ship', $chain, true) && $orderIds !== []) ? self::fetchShipTsMap($orderIds) : []; $apptMap = self::fetchAppointmentsMap($orderDateByDiag); $firstAssignMap = self::fetchFirstAssignTsMap($chunk); $inputs = []; foreach ($chunk as $did) { $diag = $diagRows[$did] ?? null; $active = $diag !== null && ($diag['delete_time'] ?? null) === null && (int) ($diag['status'] ?? 0) === 1; $assistantId = $diag !== null ? (int) ($diag['assistant_id'] ?? 0) : 0; $s = $slotMap[$did] ?? [ 'offset' => 0, 'valid_order_count' => 0, 'first_order_time' => 0, 'total_medication_days' => 0, 'latest_order' => null, ]; $lo = $s['latest_order']; $oid = $lo !== null ? (int) $lo['id'] : 0; $rxId = $lo !== null ? (int) $lo['prescription_id'] : 0; $firstCandidates = []; if ((int) $s['first_order_time'] > 0) { $firstCandidates[] = (int) $s['first_order_time']; } if ((int) ($firstAssignMap[$did] ?? 0) > 0) { $firstCandidates[] = (int) $firstAssignMap[$did]; } $inputs[$did] = [ 'diagnosis_id' => $did, 'assistant_id' => $assistantId, 'patient_name' => $diag !== null ? trim((string) ($diag['patient_name'] ?? '')) : '', 'diagnosis_active' => $active, 'latest_order' => $lo, 'valid_order_count' => (int) $s['valid_order_count'], 'offset' => (int) $s['offset'], 'rx_usage_days' => $rxId > 0 ? ($rxDaysMap[$rxId] ?? null) : null, 'sign_ts' => $oid > 0 ? (int) ($signMap[$oid] ?? 0) : 0, 'ship_ts' => $oid > 0 ? (int) ($shipMap[$oid] ?? 0) : 0, 'appointments' => $apptMap[$did] ?? [], 'first_touch_ts' => $firstCandidates !== [] ? min($firstCandidates) : 0, 'total_medication_days' => (int) $s['total_medication_days'], ]; } return $inputs; } /** * 处方 usage_days(剔除系统代开 / 作废 / 软删)。 * * @param list $rxIds * * @return array prescription_id => usage_days */ private static function fetchRxUsageDaysMap(array $rxIds): array { $out = []; try { foreach (array_chunk(array_values(array_unique($rxIds)), 2000) as $chunk) { $rows = Db::name('tcm_prescription') ->whereIn('id', $chunk) ->where('is_system_auto', 0) ->where('void_status', 0) ->whereNull('delete_time') ->column('usage_days', 'id'); foreach ($rows as $id => $days) { $out[(int) $id] = (int) $days; } } } catch (\Throwable $e) { Log::warning('复诊到期:读取处方天数失败 ' . $e->getMessage()); } return $out; } /** * 签收时间(纯读物流库,不调快递100)。 * * @param list $items * * @return array order_id => sign unix */ private static function fetchSignTsMap(array $items): array { try { $map = ExpressTrackingService::batchResolveSignUnixFromDbForOrders($items); $out = []; foreach ($map as $oid => $ts) { $out[(int) $oid] = (int) $ts; } return $out; } catch (\Throwable $e) { Log::warning('复诊到期:解析签收时间失败 ' . $e->getMessage()); return []; } } /** * 人工发货时间:tcm_prescription_order_log action='ship' 的最早 create_time。 * * @param list $orderIds * * @return array order_id => ship unix */ private static function fetchShipTsMap(array $orderIds): array { $out = []; try { foreach (array_chunk(array_values(array_unique($orderIds)), 2000) as $chunk) { $rows = Db::name('tcm_prescription_order_log') ->whereIn('prescription_order_id', $chunk) ->where('action', 'ship') ->fieldRaw('prescription_order_id, MIN(create_time) AS ship_ts') ->group('prescription_order_id') ->select() ->toArray(); foreach ($rows as $r) { $oid = (int) ($r['prescription_order_id'] ?? 0); $ts = (int) ($r['ship_ts'] ?? 0); if ($oid > 0 && $ts > 0) { $out[$oid] = $ts; } } } } catch (\Throwable $e) { Log::warning('复诊到期:读取发货日志失败 ' . $e->getMessage()); } return $out; } /** * 锚点订单日及之后的挂号(status ∈ 1,3,4)。先按最小日期取回,再按各诊单自身的订单日过滤。 * * @param array $orderDateByDiag diagnosis_id => 'Y-m-d' * * @return array> */ private static function fetchAppointmentsMap(array $orderDateByDiag): array { if ($orderDateByDiag === []) { return []; } $minDate = min(array_values($orderDateByDiag)); $out = []; $rows = Db::name('doctor_appointment') ->whereIn('patient_id', array_keys($orderDateByDiag)) ->whereIn('status', [1, 3, 4]) ->where('appointment_date', '>=', $minDate) ->field(['id', 'patient_id', 'status', 'appointment_date', 'appointment_time']) ->order(['appointment_date' => 'asc', 'appointment_time' => 'asc', 'id' => 'asc']) ->select() ->toArray(); foreach ($rows as $r) { $did = (int) ($r['patient_id'] ?? 0); if ($did <= 0 || !isset($orderDateByDiag[$did])) { continue; } $aptDate = substr(trim((string) ($r['appointment_date'] ?? '')), 0, 10); if ($aptDate === '' || $aptDate < $orderDateByDiag[$did]) { continue; } $out[$did][] = [ 'id' => (int) ($r['id'] ?? 0), 'status' => (int) ($r['status'] ?? 0), 'appointment_date' => $aptDate, 'appointment_time' => trim((string) ($r['appointment_time'] ?? '')), ]; } return $out; } /** * 首条 to_assistant_id>0 的指派时间。 * * @param list $diagIds * * @return array diagnosis_id => unix */ private static function fetchFirstAssignTsMap(array $diagIds): array { $out = []; try { $rows = Db::name('tcm_diagnosis_assign_log') ->whereIn('diagnosis_id', $diagIds) ->where('to_assistant_id', '>', 0) ->fieldRaw('diagnosis_id, MIN(create_time) AS first_ts') ->group('diagnosis_id') ->select() ->toArray(); foreach ($rows as $r) { $did = (int) ($r['diagnosis_id'] ?? 0); $ts = (int) ($r['first_ts'] ?? 0); if ($did > 0 && $ts > 0) { $out[$did] = $ts; } } } catch (\Throwable $e) { Log::warning('复诊到期:读取首次指派时间失败 ' . $e->getMessage()); } return $out; } /** * 读取快照行。 * * @param list $diagIds * * @return array> diagnosis_id => row */ private static function fetchSnapshotRows(array $diagIds): array { if ($diagIds === []) { return []; } $out = []; foreach (array_chunk($diagIds, 2000) as $chunk) { $rows = Db::name(self::TABLE)->whereIn('diagnosis_id', $chunk)->select()->toArray(); foreach ($rows as $r) { $out[(int) $r['diagnosis_id']] = $r; } } return $out; } // ---------------------------------------------------------------- 内部:状态推进与写入 /** * 规划 upsert 行与事件(纯内存,不写库)。 * * 规则: * - 新行:closed_state = closed_state_auto,不写事件(避免首次全量扫描刷屏); * - source_order_id 变化(新有效订单)或 失效→恢复:全部人工字段重置、closed_state=auto、event 6(换人再加 event 9); * - assistant_id 变化:延迟相关字段重置、next_remind_at=now、closed_state 人工关闭(4) 保留否则取 auto、event 9; * - 其余:auto=5 → 5;旧 4 保留;0→2/3 自动关闭 event 4/5;2/3→0 重开 event 7(旧 2 则 missed_count+1、next_remind_at=now);2→3 event 5。 * * @param array $facts * @param array $input * @param array|null $old * * @return array{0: array, 1: list}>, 2: array{closed:bool, reopened:bool, new_cycle:bool, assistant_changed:bool}} */ private static function planUpsert(int $did, array $facts, array $input, ?array $old, int $now, string $reason): array { $assistantId = (int) ($input['assistant_id'] ?? 0); $auto = (int) $facts['closed_state_auto']; $row = [ 'diagnosis_id' => $did, 'assistant_id' => max(0, $assistantId), 'patient_name' => mb_substr((string) ($input['patient_name'] ?? ''), 0, 64), 'source_order_id' => (int) $facts['source_order_id'], 'order_create_time' => (int) $facts['order_create_time'], 'valid_order_count' => min(65535, (int) $facts['valid_order_count']), 'anchor_ts' => (int) $facts['anchor_ts'], 'anchor_src' => (int) $facts['anchor_src'], 'medication_days' => min(65535, (int) $facts['medication_days']), 'days_src' => (int) $facts['days_src'], 'rule_days' => min(255, (int) $facts['rule_days']), 'total_medication_days' => min(65535, (int) $facts['total_medication_days']), 'current_slot' => min(255, (int) $facts['current_slot']), 'next_slot' => min(255, (int) $facts['next_slot']), 'first_touch_date' => $facts['first_touch_date'], 'due_date' => $facts['due_date'], 'appointment_state' => (int) $facts['appointment_state'], 'appointment_date' => $facts['appointment_date'], 'booked_appointment_id' => (int) $facts['booked_appointment_id'], 'computed_at' => $now, 'update_time' => $now, ]; $events = []; $flags = ['closed' => false, 'reopened' => false, 'new_cycle' => false, 'assistant_changed' => false]; $basePayload = $reason !== '' ? ['reason' => $reason] : []; if ($old === null) { $row['closed_state'] = $auto; $row['next_remind_at'] = 0; $row['create_time'] = $now; return [$row, $events, $flags]; } $oldClosed = (int) ($old['closed_state'] ?? 0); $oldSource = (int) ($old['source_order_id'] ?? 0); $oldAssistant = (int) ($old['assistant_id'] ?? 0); $newSource = (int) $row['source_order_id']; $newCycle = $newSource > 0 && $oldSource !== $newSource; $reactivated = !$newCycle && $oldClosed === RevisitDueCalculator::CLOSED_INVALID && $auto !== RevisitDueCalculator::CLOSED_INVALID; $assistantChanged = $assistantId > 0 && $oldAssistant !== $assistantId; $closedState = $oldClosed; if ($newCycle || $reactivated) { foreach (self::RESET_FIELDS_CYCLE as $k => $v) { $row[$k] = $v; } $closedState = $auto; $events[] = [ 'event' => DiagnosisRevisitDueLog::EVENT_CYCLE_RESET, 'payload' => $basePayload + [ 'from_order' => $oldSource, 'to_order' => $newSource, 'from_closed_state' => $oldClosed, 'reactivated' => $reactivated ? 1 : 0, ], ]; if ($assistantChanged) { $events[] = [ 'event' => DiagnosisRevisitDueLog::EVENT_ASSISTANT_CHANGED, 'payload' => $basePayload + ['from_assistant' => $oldAssistant, 'to_assistant' => $assistantId], ]; } $flags['new_cycle'] = true; $flags['assistant_changed'] = $assistantChanged; } elseif ($assistantChanged) { foreach (self::RESET_FIELDS_ASSISTANT as $k => $v) { $row[$k] = $v; } $row['next_remind_at'] = $now; if ($auto === RevisitDueCalculator::CLOSED_INVALID) { $closedState = RevisitDueCalculator::CLOSED_INVALID; } elseif ($oldClosed === RevisitDueCalculator::CLOSED_MANUAL) { $closedState = RevisitDueCalculator::CLOSED_MANUAL; } else { $closedState = $auto; } $events[] = [ 'event' => DiagnosisRevisitDueLog::EVENT_ASSISTANT_CHANGED, 'payload' => $basePayload + ['from_assistant' => $oldAssistant, 'to_assistant' => $assistantId], ]; $flags['assistant_changed'] = true; } else { $openLike = $oldClosed === RevisitDueCalculator::CLOSED_OPEN; $bookedLike = in_array($oldClosed, [RevisitDueCalculator::CLOSED_BOOKED, RevisitDueCalculator::CLOSED_VISITED], true); if ($auto === RevisitDueCalculator::CLOSED_INVALID) { $closedState = RevisitDueCalculator::CLOSED_INVALID; } elseif ($oldClosed === RevisitDueCalculator::CLOSED_MANUAL) { $closedState = RevisitDueCalculator::CLOSED_MANUAL; } elseif ($openLike && in_array($auto, [RevisitDueCalculator::CLOSED_BOOKED, RevisitDueCalculator::CLOSED_VISITED], true)) { $closedState = $auto; $events[] = [ 'event' => $auto === RevisitDueCalculator::CLOSED_BOOKED ? DiagnosisRevisitDueLog::EVENT_AUTO_CLOSE_BOOKED : DiagnosisRevisitDueLog::EVENT_AUTO_CLOSE_VISITED, 'payload' => $basePayload + [ 'appointment_id' => (int) $row['booked_appointment_id'], 'appointment_date' => (string) ($row['appointment_date'] ?? ''), ], ]; } elseif ($bookedLike && $auto === RevisitDueCalculator::CLOSED_OPEN) { $closedState = RevisitDueCalculator::CLOSED_OPEN; $row['next_remind_at'] = $now; if ($oldClosed === RevisitDueCalculator::CLOSED_BOOKED) { $row['missed_count'] = min(255, (int) ($old['missed_count'] ?? 0) + 1); } $events[] = [ 'event' => DiagnosisRevisitDueLog::EVENT_REOPEN, 'payload' => $basePayload + [ 'from_closed_state' => $oldClosed, 'appointment_id' => (int) $row['booked_appointment_id'], 'appointment_state' => (int) $row['appointment_state'], 'old_appointment_id' => (int) ($old['booked_appointment_id'] ?? 0), ], ]; } elseif ($oldClosed === RevisitDueCalculator::CLOSED_BOOKED && $auto === RevisitDueCalculator::CLOSED_VISITED) { $closedState = RevisitDueCalculator::CLOSED_VISITED; $events[] = [ 'event' => DiagnosisRevisitDueLog::EVENT_AUTO_CLOSE_VISITED, 'payload' => $basePayload + ['appointment_id' => (int) $row['booked_appointment_id']], ]; } else { $closedState = $auto; } } $row['closed_state'] = $closedState; $closedSet = [RevisitDueCalculator::CLOSED_BOOKED, RevisitDueCalculator::CLOSED_VISITED, RevisitDueCalculator::CLOSED_INVALID]; if (in_array($closedState, $closedSet, true) && !in_array($oldClosed, $closedSet, true)) { $flags['closed'] = true; } if ($closedState === RevisitDueCalculator::CLOSED_OPEN && in_array($oldClosed, [RevisitDueCalculator::CLOSED_BOOKED, RevisitDueCalculator::CLOSED_VISITED], true) && !$flags['new_cycle']) { $flags['reopened'] = true; } return [$row, $events, $flags]; } /** * INSERT ... ON DUPLICATE KEY UPDATE(uk_diagnosis),create_time 仅插入时写。 * * @param array $row */ private static function upsertRow(array $row, int $now, bool $protectManualClose = true): void { if (!array_key_exists('create_time', $row)) { $row['create_time'] = $now; } $dup = []; foreach (array_keys($row) as $col) { if ($col === 'diagnosis_id' || $col === 'create_time') { continue; } if ($col === 'closed_state' && $protectManualClose) { // 列级隔离:库里已被人工关闭(4)的行不被 cron/自愈的自动状态覆盖(新周期除外) $dup['closed_state'] = Db::raw( 'IF(closed_state = ' . RevisitDueCalculator::CLOSED_MANUAL . ', ' . RevisitDueCalculator::CLOSED_MANUAL . ', VALUES(closed_state))' ); continue; } $dup[] = $col; } Db::name(self::TABLE)->duplicate($dup)->insert($row); } // ---------------------------------------------------------------- 内部:工具 /** * @param array $ids * * @return list 去重、>0、保持首次出现顺序 */ private static function normalizeIds(array $ids): array { $seen = []; $out = []; foreach ($ids as $id) { $id = (int) $id; if ($id > 0 && !isset($seen[$id])) { $seen[$id] = true; $out[] = $id; } } return $out; } /** 日期列 → '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; } }