Files
zyt/server/app/common/service/tcm/RevisitDueService.php
T
2026-10-15 11:03:54 +08:00

1032 lines
42 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\common\service\tcm;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\tcm\DiagnosisRevisitDueLog;
use app\common\service\ExpressTrackingService;
use think\facade\Db;
use think\facade\Log;
/**
* 复诊到期快照服务:取数 → RevisitDueCalculator → upsert tcm_diagnosis_revisit_due → 写事件流水。
*
* 入口:
* - refreshDiagnoses():cron(tcm:revisit-due-scan)/ 业务钩子(RevisitDueHook)/ 列表自愈 共用的单点刷新;
* - reevaluateBookedRows():closed_state=2 或 appointment_state=1 的行重算(过号 cron 之后、增量 cron);
* - candidateDiagnosisIds():全量 / 增量候选诊单;
* - buildListPayload():诊单列表 / 详情消费的 revisit_due 对象(契约 C),含自愈;
* - writeLog():事件流水(弹窗轮询不写流水)。
*
* 并发:File 缓存无锁,快照 upsert 依赖 uk_diagnosis 幂等;延迟/已联系等人工字段只在新周期 / 换医助时由本服务重置,
* 其余时间不触碰(与需求 4 的接口列级隔离)。
*/
final class RevisitDueService
{
/** 快照表(Db::name 不带前缀) */
public const TABLE = 'tcm_diagnosis_revisit_due';
/** 流水表 */
public const LOG_TABLE = 'tcm_diagnosis_revisit_due_log';
/** refreshDiagnoses 内部单批诊单数(取数 SQL 的 IN 规模) */
public const REFRESH_CHUNK = 500;
/** 列表自愈:仅当请求 page_size 不超过该值时刷新 */
private const SELF_HEAL_MAX_PAGE_SIZE = 100;
/** 列表自愈:每页最多刷新前 N 行 */
private const SELF_HEAL_MAX_ROWS = 50;
/** 新周期 / 失效恢复时全部重置的人工字段 */
private const RESET_FIELDS_CYCLE = [
'snooze_count' => 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<string, mixed>
*/
public static function config(): array
{
$raw = config('project.revisit_reach');
return RevisitDueCalculator::normalizeConfig(is_array($raw) ? $raw : []);
}
/**
* 批量刷新诊单快照。
*
* @param list<int> $diagnosisIds
* @param array<string, mixed> $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<array<string,mixed>>}
*/
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<int>
*/
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<int> $diagnosisIds 保持页面顺序
* @param array<string, mixed> $opts self_heal(bool, 默认 true)、page_size(int)、today('Y-m-d')
*
* @return array<int, array<string, mixed>> 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<string, mixed> $row 快照表一行
* @param array<string, mixed> $cfg revisit_reach 配置(已归一)
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $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<int> $chunk
* @param array<string, mixed> $cfg
*
* @return array<int, array<string, mixed>> 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<int> $rxIds
*
* @return array<int, int> 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<array{order_id:int, tracking_number:string}> $items
*
* @return array<int, int> 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<int> $orderIds
*
* @return array<int, int> 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<int, string> $orderDateByDiag diagnosis_id => 'Y-m-d'
*
* @return array<int, list<array{id:int, status:int, appointment_date:string, appointment_time:string}>>
*/
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<int> $diagIds
*
* @return array<int, int> 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<int> $diagIds
*
* @return array<int, array<string, mixed>> 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<string, mixed> $facts
* @param array<string, mixed> $input
* @param array<string, mixed>|null $old
*
* @return array{0: array<string, mixed>, 1: list<array{event:int, payload:array<string, mixed>}>, 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<string, mixed> $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<int|string, mixed> $ids
*
* @return list<int> 去重、>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;
}
}