1032 lines
42 KiB
PHP
1032 lines
42 KiB
PHP
<?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;
|
||
}
|
||
}
|