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

279 lines
11 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\stats\YejiStatsLogic;
use think\facade\Db;
/**
* 诊次(第几诊)口径单点实现,复诊接诊率 / 诊单列表「复诊节点」/ 到期快照共用。
*
* 口径(与 RevisitRateLogic 完全一致):
* - 有效业务订单 E(D):tcm_prescription_order 中 delete_time IS NULL 且 fulfillment_status IS NULL OR NOT IN (4,9,10)
* (统一经 YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery),按 (create_time, id) 升序;
* - 偏移 off(D) = tcm_diagnosis.revisit_slot_start_offset 钳到 0~20;
* - 第 n 笔有效订单的诊次 = n + off(D);患者当前已完成诊次 current_slot = |E(D)| + off(D);待复诊诊次 next_slot = current_slot + 1;
* - 某时刻 t 患者处于第几诊 slot_at(D, t) = |{o : create_time(o) < t}| + off(D)(严格小于,与持有人规则 t <= ct 对偶)。
*
* MySQL 5.7 无窗口函数:batchCurrentSlot 先 GROUP BY 聚合,再按 (diagnosis_id, create_time) 回表,PHP 侧取 id 最大的一行。
*/
final class RevisitVisitSlotService
{
/** 偏移量上限(与 RevisitRateLogic 历史实现一致) */
public const MAX_OFFSET = 20;
/** 单次 SQL 的诊单分块大小 */
private const CHUNK_SIZE = 2000;
/**
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
* 从 RevisitRateLogic::fetchOrderSeqRows 搬出,行为不变(调用方自行分块)。
*
* @param list<int> $diagnosisIds
* @param list<string> $fields 需带 `o.` 别名前缀,如 ['o.id', 'o.diagnosis_id', 'o.create_time']
*
* @return list<array<string, mixed>>
*/
public static function fetchOrderSeqRows(array $diagnosisIds, array $fields): array
{
if ($diagnosisIds === []) {
return [];
}
$q = Db::name('tcm_prescription_order')
->alias('o')
->whereIn('o.diagnosis_id', $diagnosisIds)
->whereNull('o.delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
return $q
->field($fields)
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
->select()
->toArray();
}
/**
* 诊单 => revisit_slot_start_offset(未钳制的原始值;已软删诊单不返回)。
* 从 RevisitRateLogic::fetchRevisitSlotStartOffsetMap 搬出,行为不变。
*
* @param list<int> $diagnosisIds
*
* @return array<int, int>
*/
public static function fetchRevisitSlotStartOffsetMap(array $diagnosisIds): array
{
if ($diagnosisIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagnosisIds, self::CHUNK_SIZE) as $chunk) {
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $chunk)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
foreach ($rows as $id => $offset) {
$out[(int) $id] = (int) $offset;
}
}
return $out;
}
/**
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移),钳到 0~20。
* 从 RevisitRateLogic::resolveRevisitSlotStartOffset 搬出,行为不变。
*
* @param array<int, int> $map diagnosis_id => offset
*/
public static function resolveRevisitSlotStartOffset(int $diagnosisId, array $map): int
{
$offset = (int) ($map[$diagnosisId] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > self::MAX_OFFSET) {
$offset = self::MAX_OFFSET;
}
return $offset;
}
/**
* 纯函数:时刻 atTs 之前(严格小于)的有效订单数 + 偏移 = 该时刻患者已完成的诊次。
* 同一秒的订单视为「指派之后」,不计入(与持有人规则 t <= ct 对偶)。
*
* @param list<int|string> $orderCreateTimes 有效订单的 create_time 列表(顺序不限)
*/
public static function slotBefore(array $orderCreateTimes, int $offset, int $atTs): int
{
$n = 0;
foreach ($orderCreateTimes as $ct) {
if ((int) $ct < $atTs) {
$n++;
}
}
if ($offset < 0) {
$offset = 0;
}
if ($offset > self::MAX_OFFSET) {
$offset = self::MAX_OFFSET;
}
return $n + $offset;
}
/**
* 某时刻患者处于第几诊(含偏移)。诊单不存在或无订单时返回偏移值。
*/
public static function slotAtTime(int $diagnosisId, int $ts): int
{
if ($diagnosisId <= 0) {
return 0;
}
$offsetMap = self::fetchRevisitSlotStartOffsetMap([$diagnosisId]);
$offset = self::resolveRevisitSlotStartOffset($diagnosisId, $offsetMap);
$rows = self::fetchOrderSeqRows([$diagnosisId], ['o.create_time']);
$cts = [];
foreach ($rows as $r) {
$cts[] = (int) ($r['create_time'] ?? 0);
}
return self::slotBefore($cts, $offset, $ts);
}
/**
* 批量计算每个诊单的当前诊次与最新有效订单(每个入参诊单都有返回,无订单时 latest_order 为 null)。
*
* @param list<int> $diagnosisIds
*
* @return array<int, array{
* diagnosis_id:int, offset:int, valid_order_count:int, current_slot:int, next_slot:int,
* first_order_time:int, total_medication_days:int,
* latest_order:null|array{id:int, order_no:string, create_time:int, medication_days:int, prescription_id:int, tracking_number:string, fulfillment_status:int|null, creator_id:int}
* }>
*/
public static function batchCurrentSlot(array $diagnosisIds): array
{
$ids = [];
foreach ($diagnosisIds as $id) {
$id = (int) $id;
if ($id > 0) {
$ids[$id] = true;
}
}
$ids = array_keys($ids);
if ($ids === []) {
return [];
}
$offsetMap = self::fetchRevisitSlotStartOffsetMap($ids);
/** @var array<int, array{cnt:int, max_ct:int, min_ct:int, sum_days:int}> $agg */
$agg = [];
/** @var array<int, array<string, mixed>> $latest diagnosis_id => 最新有效订单行 */
$latest = [];
foreach (array_chunk($ids, self::CHUNK_SIZE) as $chunk) {
// 1. 聚合:有效订单数 / 最新与最早下单时间 / 累计开药天数
// 累计开药天数按「订单 medication_days ?: 处方 usage_days」逐单回落(处方剔除系统代开/作废/软删,一单对一方,不会放大行数)
$q = Db::name('tcm_prescription_order')
->alias('o')
->leftJoin('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.is_system_auto = 0 AND rx.void_status = 0 AND rx.delete_time IS NULL')
->whereIn('o.diagnosis_id', $chunk)
->whereNull('o.delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
$aggRows = $q
->fieldRaw('o.diagnosis_id, COUNT(*) AS cnt, MAX(o.create_time) AS max_ct, MIN(o.create_time) AS min_ct, SUM(COALESCE(NULLIF(o.medication_days, 0), NULLIF(rx.usage_days, 0), 0)) AS sum_days')
->group('o.diagnosis_id')
->select()
->toArray();
$maxCts = [];
$chunkDiagWithOrders = [];
foreach ($aggRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
continue;
}
$maxCt = (int) ($r['max_ct'] ?? 0);
$agg[$did] = [
'cnt' => (int) ($r['cnt'] ?? 0),
'max_ct' => $maxCt,
'min_ct' => (int) ($r['min_ct'] ?? 0),
'sum_days' => (int) ($r['sum_days'] ?? 0),
];
$maxCts[$maxCt] = true;
$chunkDiagWithOrders[] = $did;
}
if ($chunkDiagWithOrders === []) {
continue;
}
// 2. 回表:按 (diagnosis_id, create_time) 取候选行,PHP 侧过滤精确匹配并取 id 最大者
$q2 = Db::name('tcm_prescription_order')
->alias('o')
->whereIn('o.diagnosis_id', $chunkDiagWithOrders)
->whereIn('o.create_time', array_keys($maxCts))
->whereNull('o.delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q2, 'o');
$candRows = $q2
->field([
'o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.medication_days',
'o.prescription_id', 'o.tracking_number', 'o.fulfillment_status', 'o.creator_id',
])
->select()
->toArray();
foreach ($candRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0 || !isset($agg[$did])) {
continue;
}
if ((int) ($r['create_time'] ?? 0) !== $agg[$did]['max_ct']) {
continue;
}
$oid = (int) ($r['id'] ?? 0);
if (!isset($latest[$did]) || $oid > (int) ($latest[$did]['id'] ?? 0)) {
$latest[$did] = $r;
}
}
}
$out = [];
foreach ($ids as $did) {
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
$a = $agg[$did] ?? null;
$cnt = $a !== null ? $a['cnt'] : 0;
$currentSlot = $cnt + $offset;
$lo = $latest[$did] ?? null;
$latestOrder = null;
if ($lo !== null) {
$fs = $lo['fulfillment_status'] ?? null;
$latestOrder = [
'id' => (int) ($lo['id'] ?? 0),
'order_no' => (string) ($lo['order_no'] ?? ''),
'create_time' => (int) ($lo['create_time'] ?? 0),
'medication_days' => (int) ($lo['medication_days'] ?? 0),
'prescription_id' => (int) ($lo['prescription_id'] ?? 0),
'tracking_number' => trim((string) ($lo['tracking_number'] ?? '')),
'fulfillment_status' => $fs === null ? null : (int) $fs,
'creator_id' => (int) ($lo['creator_id'] ?? 0),
];
}
$out[$did] = [
'diagnosis_id' => $did,
'offset' => $offset,
'valid_order_count' => $cnt,
'current_slot' => $currentSlot,
'next_slot' => $currentSlot + 1,
'first_order_time' => $a !== null ? $a['min_ct'] : 0,
'total_medication_days' => $a !== null ? $a['sum_days'] : 0,
'latest_order' => $latestOrder,
];
}
return $out;
}
}