This commit is contained in:
gr
2026-10-15 11:03:54 +08:00
parent 70ddc1a70a
commit bb28d93cec
64 changed files with 12747 additions and 420 deletions
@@ -0,0 +1,278 @@
<?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;
}
}