This commit is contained in:
Your Name
2026-10-17 16:08:24 +08:00
parent c67b3eff69
commit 56f85f7d89
57 changed files with 4170 additions and 57 deletions
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\RevisitCohortLogic;
/**
* 新指派复诊接诊率(按指派日期选患者,订单累计至查询时刻)
*
* - GET stats.revisitAssignRate/overview N 诊接诊率(部门 → 医助分组 + 合计)
* - GET stats.revisitAssignRate/deptOptions 部门下拉(前端组树)
* - GET stats.revisitAssignRate/assignLines 「被指派数」明细(按诊单聚合)
* - GET stats.revisitAssignRate/visitOrderLines 「N 诊单数」订单明细
*/
class RevisitAssignRateController extends BaseAdminController
{
protected const COHORT_TYPE = 'new';
public function overview()
{
// 涉及指派日志 + 全量订单序列扫描,放宽执行时间兜底
@set_time_limit(120);
$params = $this->request->get();
try {
return $this->data(RevisitCohortLogic::overview($params, static::COHORT_TYPE));
} catch (\InvalidArgumentException $e) {
return $this->fail($e->getMessage());
}
}
public function deptOptions()
{
return $this->data(RevisitCohortLogic::deptOptions());
}
/** 「被指派数」点击下钻:诊单维度明细 */
public function assignLines()
{
@set_time_limit(120);
$params = $this->request->get();
try {
return $this->data(RevisitCohortLogic::assignLines($params, static::COHORT_TYPE));
} catch (\InvalidArgumentException $e) {
return $this->fail($e->getMessage());
}
}
/** 「N 诊单数」点击下钻:具体订单明细 */
public function visitOrderLines()
{
@set_time_limit(120);
$params = $this->request->get();
try {
return $this->data(RevisitCohortLogic::visitOrderLines($params, static::COHORT_TYPE));
} catch (\InvalidArgumentException $e) {
return $this->fail($e->getMessage());
}
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
/** 继承复诊接诊率独立接口,统计类型由服务端固定,权限与新指派页分开登记。 */
class RevisitInheritRateController extends RevisitAssignRateController
{
protected const COHORT_TYPE = 'inherit';
}
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\service\tcm\RevisitVisitSlotService;
/** 指派日期队列:先圈定患者,再累计其持有期间的有效复诊订单;不改变全局诊次。 */
final class RevisitCohortCalculator
{
public const TYPE_NEW = 'new';
public const TYPE_INHERIT = 'inherit';
/**
* 日志与订单分别按 (diagnosis_id, create_time, id) 升序。
* 新指派保留全局诊次;继承每次从二诊重新起算,同一秒订单归最新指派。
* 同一医助、诊单、诊次只计一次,避免反复继承把单诊接诊率推高至 100% 以上。
*/
public static function compute(
array $logRows,
iterable $orderRows,
array $offsetMap,
array $refundRejectSet,
array $rollbackKeySet,
int $startTs,
int $endTs,
int $observedAt,
string $type
): array {
$type = $type === self::TYPE_INHERIT ? self::TYPE_INHERIT : self::TYPE_NEW;
$timeline = [];
$pairs = [];
$lastTarget = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$from = (int) ($r['from_assistant_id'] ?? 0);
$time = (int) ($r['create_time'] ?? 0);
if ($did <= 0 || $time > $observedAt) {
continue;
}
// 从未分配重新指派时,写入侧可能把 from 回填为原医助,形成 A→A。
// 以实际时间线判断持有人是否变化,保留释放后的重新承接。
$previous = $lastTarget[$did] ?? null;
if (($previous !== null && $previous === $aid) || ($previous === null && $from === $aid)) {
continue;
}
$lastTarget[$did] = $aid;
$isRollback = isset($rollbackKeySet[RevisitRateCalculator::rollbackKey(
$did, $time, (int) ($r['operator_admin_id'] ?? 0)
)]);
$eventType = (int) ($r['is_inherit'] ?? 0) === 1 || $isRollback
? self::TYPE_INHERIT : self::TYPE_NEW;
$selected = $aid > 0 && $time >= $startTs && $time <= $endTs
&& $eventType === $type && !isset($refundRejectSet[$did]);
$timeline[$did][] = [
'assistant_id' => $aid,
'time' => $time,
'selected' => $selected,
'type' => $eventType,
];
if (!$selected) {
continue;
}
if (!isset($pairs[$aid][$did])) {
$pairs[$aid][$did] = [
'diagnosis_id' => $did,
'assistant_id' => $aid,
'assign_type' => $type,
'assign_count' => 0,
'first_assign_time' => $time,
'last_assign_time' => $time,
'is_rollback' => 0,
];
}
$pairs[$aid][$did]['assign_count']++;
$pairs[$aid][$did]['last_assign_time'] = $time;
$pairs[$aid][$did]['is_rollback'] |= $isRollback ? 1 : 0;
}
$orders = [];
$curDid = 0;
$globalSeq = 0;
$segmentSeq = 0;
$ptr = 0;
$offset = 0;
$event = null;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$ct = (int) ($r['create_time'] ?? 0);
if ($did <= 0 || $ct > $observedAt) {
continue;
}
if ($curDid !== $did) {
$curDid = $did;
$globalSeq = 0;
$segmentSeq = 0;
$ptr = 0;
$event = null;
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$globalSeq++;
$tl = $timeline[$did] ?? [];
while ($ptr < \count($tl) && $tl[$ptr]['time'] <= $ct) {
$event = $tl[$ptr++];
$segmentSeq = 0;
}
if ($event === null) {
continue;
}
$segmentSeq++;
if (!$event['selected']) {
continue;
}
$slot = $type === self::TYPE_INHERIT ? $segmentSeq + 1 : $globalSeq + $offset;
if ($slot < 2 || $slot > RevisitRateCalculator::MAX_VISIT_SLOT) {
continue;
}
$aid = $event['assistant_id'];
// 重复承接同一患者时,明细保留首次达到该诊次的订单,与人数指标逐项对账。
if (isset($orders[$aid][$slot][$did])) {
continue;
}
$r['assistant_id'] = $aid;
$r['visit_slot'] = $slot;
$r['global_visit_slot'] = $globalSeq + $offset;
$r['cohort_assign_time'] = $event['time'];
$orders[$aid][$slot][$did] = $r;
}
return ['pairsByAssistant' => $pairs, 'ordersByAssistant' => $orders, 'cohort_type' => $type];
}
/** 部门与总计按诊单去重;每个诊次保留同一患者最早一笔订单。 */
public static function scopedOrders(array $calc, array $assistantIds, int $slot = 0): array
{
$out = [];
foreach ($assistantIds as $aid) {
foreach ($calc['ordersByAssistant'][$aid] ?? [] as $s => $byDiag) {
if ($slot > 0 && $s !== $slot) {
continue;
}
foreach ($byDiag as $did => $r) {
$previous = $out[$s][$did] ?? null;
if ($previous === null || [(int) $r['create_time'], (int) $r['id']]
< [(int) $previous['create_time'], (int) $previous['id']]) {
$out[$s][$did] = $r;
}
}
}
}
return $out;
}
public static function metricPack(array $calc, array $assistantIds, array $slots): array
{
$diagSet = [];
foreach ($assistantIds as $aid) {
foreach ($calc['pairsByAssistant'][$aid] ?? [] as $did => $_) {
$diagSet[$did] = true;
}
}
$count = \count($diagSet);
$inherit = $calc['cohort_type'] === self::TYPE_INHERIT;
$pack = [
'assigned_count' => $inherit ? 0 : $count,
'inherit_count' => $inherit ? $count : 0,
'assigned_total_count' => $count,
];
$orders = self::scopedOrders($calc, $assistantIds);
$all = array_sum(array_map('count', $orders));
foreach ($slots as $slot) {
$num = \count($orders[$slot] ?? []);
$pack['visit' . $slot . '_count'] = $num;
$pack['visit' . $slot . '_den'] = $count;
$pack['visit' . $slot . '_rate'] = $count > 0 ? round($num / $count * 100, 2) : null;
}
$pack['visit_all_count'] = $all;
$pack['visit_all_den'] = $count;
$pack['visit_all_rate'] = $count > 0 ? round($all / $count * 100, 2) : null;
return $pack;
}
/** 严格校验日期,避免非法日期被 strtotime 自动滚到次月。 */
public static function dateRange(array $params, int $now): array
{
$start = trim((string) ($params['start_date'] ?? ''));
$end = trim((string) ($params['end_date'] ?? ''));
if ($start === '' && $end === '') {
$month = trim((string) ($params['month'] ?? ''));
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) {
$start = $month . '-01';
$end = date('Y-m-t', (int) strtotime($start));
} else {
$start = date('Y-m-01', $now);
$end = date('Y-m-d', $now);
}
}
foreach ([$start, $end] as $date) {
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
throw new \InvalidArgumentException('请选择有效的开始日期和结束日期');
}
}
if ($start > $end) {
throw new \InvalidArgumentException('开始日期不能晚于结束日期');
}
return [$start, $end, (int) strtotime($start . ' 00:00:00'), (int) strtotime($end . ' 23:59:59')];
}
}
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\service\tcm\RevisitVisitSlotService;
use think\facade\Db;
/** 新报表独立读取方法,保持原复诊率 Logic 源码及调用链不变。 */
trait RevisitCohortDataSource
{
private static function fetchRollbackKeySet(int $startTs, int $endTs): array
{
$rows = Db::name('tcm_diagnosis_auto_assign_log')
->where('rollback_time', '>=', $startTs)
->where('rollback_time', '<=', $endTs)
->where('diagnosis_id', '>', 0)
->field(['diagnosis_id', 'rollback_time', 'rollback_admin_id'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[RevisitRateCalculator::rollbackKey(
(int) ($r['diagnosis_id'] ?? 0),
(int) ($r['rollback_time'] ?? 0),
(int) ($r['rollback_admin_id'] ?? 0)
)] = true;
}
return $out;
}
private static function iterateOrderSeqRows(array $diagIds, array $fields): \Generator
{
foreach (array_chunk($diagIds, 2000) as $chunk) {
foreach (self::fetchOrderSeqRows($chunk, $fields) as $r) {
yield $r;
}
}
}
private static function buildAssistantDeptIndex(array $adminIds): array
{
if ($adminIds === []) {
return [[], []];
}
// admin_dept 为 (admin_id, dept_id) 联合主键、无自增 id;取最小 dept_id 作为归属部门保证确定性
$relRows = Db::name('admin_dept')
->whereIn('admin_id', $adminIds)
->order(['admin_id' => 'asc', 'dept_id' => 'asc'])
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$deptNames = Db::name('dept')
->whereNull('delete_time')
->column('name', 'id');
$canonical = [];
foreach ($relRows as $r) {
$aid = (int) ($r['admin_id'] ?? 0);
$deptId = (int) ($r['dept_id'] ?? 0);
if ($aid <= 0 || $deptId <= 0 || isset($canonical[$aid])) {
continue;
}
if (!isset($deptNames[$deptId])) {
continue;
}
$canonical[$aid] = $deptId;
}
$names = [];
foreach ($deptNames as $id => $name) {
$names[(int) $id] = (string) $name;
}
return [$canonical, $names];
}
private static function resolveDeptFilterSet(mixed $raw): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return [];
}
$deptFilterIds = self::parseDeptIds($raw);
if ($deptFilterIds === []) {
return $erSet;
}
$allowedRoots = [];
foreach ($deptFilterIds as $id) {
if (isset($erSet[$id])) {
$allowedRoots[] = $id;
}
}
if ($allowedRoots === []) {
return [];
}
$expanded = self::expandDeptSubtreeSet($allowedRoots);
$out = [];
foreach ($expanded as $id => $_) {
if (isset($erSet[$id])) {
$out[$id] = true;
}
}
return $out;
}
private static function parseDeptIds(mixed $raw): array
{
if ($raw === null || $raw === '' || $raw === []) {
return [];
}
$list = \is_array($raw) ? $raw : explode(',', (string) $raw);
return array_values(array_unique(array_filter(
array_map('intval', $list),
static fn (int $v): bool => $v > 0
)));
}
private static function expandDeptSubtreeSet(array $deptIds): array
{
$rows = Db::name('dept')
->whereNull('delete_time')
->field(['id', 'pid'])
->select()
->toArray();
$childrenByPid = [];
foreach ($rows as $r) {
$childrenByPid[(int) $r['pid']][] = (int) $r['id'];
}
$set = [];
$queue = $deptIds;
while ($queue !== []) {
$id = (int) array_shift($queue);
if ($id <= 0 || isset($set[$id])) {
continue;
}
$set[$id] = true;
foreach ($childrenByPid[$id] ?? [] as $childId) {
$queue[] = $childId;
}
}
return $set;
}
private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$ids = Db::name('tcm_prescription_order')
->whereIn('diagnosis_id', $chunk)
->whereNull('delete_time')
->whereIn('fulfillment_status', [9, 10])
->group('diagnosis_id')
->column('diagnosis_id');
foreach ($ids as $id) {
$out[(int) $id] = true;
}
}
return $out;
}
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
{
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields);
}
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
{
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
}
private static function fetchDiagnosisInfo(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $diagIds)
->field(['id', 'patient_name', 'phone'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
'phone' => trim((string) ($r['phone'] ?? '')),
];
}
return $out;
}
private static function cohortDeptOptions(): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return ['rows' => []];
}
$rows = Db::name('dept')
->whereNull('delete_time')
->whereIn('id', array_keys($erSet))
->field(['id', 'pid', 'name'])
->order('sort', 'desc')
->order('id', 'asc')
->select()
->toArray();
return [
'rows' => array_map(static fn (array $r): array => [
'id' => (int) $r['id'],
'pid' => (int) $r['pid'],
'name' => (string) $r['name'],
], $rows),
];
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use think\facade\Db;
/** 指派 / 继承独立报表。旧按月计算器继续服务自动分配与个人业绩。 */
class RevisitCohortLogic
{
use RevisitCohortDataSource;
public static function overview(array $params, string $type = RevisitCohortCalculator::TYPE_NEW): array
{
$ctx = self::cohortContext($params, $type);
$calc = $ctx['calc'];
$aids = array_keys($calc['pairsByAssistant']);
$names = $aids === [] ? [] : Db::name('admin')->whereIn('id', $aids)->column('name', 'id');
$maxSlot = 4;
foreach ($calc['ordersByAssistant'] as $bySlot) {
foreach ($bySlot as $slot => $_) {
$maxSlot = max($maxSlot, $slot);
}
}
$slots = range(2, $maxSlot);
$byDept = [];
foreach ($aids as $aid) {
$deptId = $ctx['assistantDept'][$aid];
$byDept[$deptId][] = $aid;
}
$rows = [];
foreach ($byDept as $deptId => $deptAids) {
$children = [];
$deptName = (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId));
foreach ($deptAids as $aid) {
$children[] = [
'row_key' => 'a' . $aid,
'is_dept' => 0,
'dept_id' => $deptId,
'dept_name' => $deptName,
'assistant_id' => $aid,
'assistant_name' => (string) ($names[$aid] ?? ('#' . $aid)),
] + RevisitCohortCalculator::metricPack($calc, [$aid], $slots);
}
usort($children, static fn (array $a, array $b): int =>
($b['assigned_total_count'] <=> $a['assigned_total_count'])
?: strcmp($a['assistant_name'], $b['assistant_name']));
$rows[] = [
'row_key' => 'd' . $deptId,
'is_dept' => 1,
'dept_id' => $deptId,
'dept_name' => $deptName,
'assistant_count' => \count($children),
'children' => $children,
] + RevisitCohortCalculator::metricPack($calc, $deptAids, $slots);
}
usort($rows, static fn (array $a, array $b): int =>
($b['assigned_total_count'] <=> $a['assigned_total_count'])
?: strcmp($a['dept_name'], $b['dept_name']));
return self::metadata($ctx) + [
'slots' => $slots,
'total' => RevisitCohortCalculator::metricPack($calc, $aids, $slots),
'rows' => $rows,
];
}
public static function assignLines(array $params, string $type = RevisitCohortCalculator::TYPE_NEW): array
{
$ctx = self::cohortContext($params, $type);
$aids = self::scopedAssistantIds($ctx, $params);
$byDiag = [];
foreach ($aids as $aid) {
foreach ($ctx['calc']['pairsByAssistant'][$aid] ?? [] as $did => $pair) {
if (!isset($byDiag[$did])) {
$byDiag[$did] = $pair + ['assistants' => []];
$byDiag[$did]['assign_count'] = 0;
}
$byDiag[$did]['assistants'][$aid] = true;
$byDiag[$did]['assign_count'] += $pair['assign_count'];
$byDiag[$did]['first_assign_time'] = min($byDiag[$did]['first_assign_time'], $pair['first_assign_time']);
$byDiag[$did]['last_assign_time'] = max($byDiag[$did]['last_assign_time'], $pair['last_assign_time']);
$byDiag[$did]['is_rollback'] |= $pair['is_rollback'];
}
}
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
$names = $aids === [] ? [] : Db::name('admin')->whereIn('id', $aids)->column('name', 'id');
$rows = [];
foreach ($byDiag as $did => $pair) {
$assistantNames = [];
foreach ($pair['assistants'] as $aid => $_) {
$assistantNames[] = (string) ($names[$aid] ?? ('#' . $aid));
}
$rows[] = [
'diagnosis_id' => $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'assistant_names' => implode('、', $assistantNames),
'assign_count' => $pair['assign_count'],
'first_assign_time_text' => date('Y-m-d H:i:s', $pair['first_assign_time']),
'last_assign_time' => $pair['last_assign_time'],
'last_assign_time_text' => date('Y-m-d H:i:s', $pair['last_assign_time']),
'assign_type' => $ctx['calc']['cohort_type'],
'is_rollback' => $pair['is_rollback'],
];
}
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
return self::metadata($ctx) + ['count' => \count($rows), 'rows' => $rows];
}
public static function visitOrderLines(array $params, string $type = RevisitCohortCalculator::TYPE_NEW): array
{
$slot = (int) ($params['slot'] ?? 0);
if ($slot < 0 || $slot === 1 || $slot > RevisitRateCalculator::MAX_VISIT_SLOT) {
throw new \InvalidArgumentException('诊次须为 2~50,或 0(复诊合计)');
}
$ctx = self::cohortContext($params, $type);
$orders = RevisitCohortCalculator::scopedOrders($ctx['calc'], self::scopedAssistantIds($ctx, $params), $slot);
$flat = [];
$adminIds = [];
$diagIds = [];
foreach ($orders as $byDiag) {
foreach ($byDiag as $did => $r) {
$flat[] = $r;
$diagIds[$did] = true;
$adminIds[(int) $r['assistant_id']] = true;
if ((int) ($r['creator_id'] ?? 0) > 0) {
$adminIds[(int) $r['creator_id']] = true;
}
}
}
$diagInfo = self::fetchDiagnosisInfo(array_keys($diagIds));
$names = $adminIds === [] ? [] : Db::name('admin')->whereIn('id', array_keys($adminIds))->column('name', 'id');
$rows = [];
foreach ($flat as $r) {
$did = (int) $r['diagnosis_id'];
$aid = (int) $r['assistant_id'];
$cid = (int) ($r['creator_id'] ?? 0);
$rows[] = [
'order_id' => (int) $r['id'],
'order_no' => (string) ($r['order_no'] ?? ''),
'diagnosis_id' => $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'amount' => round((float) ($r['amount'] ?? 0), 2),
'create_time' => (int) $r['create_time'],
'create_time_text' => date('Y-m-d H:i:s', (int) $r['create_time']),
'assistant_id' => $aid,
'assistant_name' => (string) ($names[$aid] ?? ('#' . $aid)),
'creator_name' => $cid > 0 ? (string) ($names[$cid] ?? ('#' . $cid)) : '—',
'visit_slot' => (int) $r['visit_slot'],
'global_visit_slot' => (int) $r['global_visit_slot'],
'cohort_assign_time_text' => date('Y-m-d H:i:s', (int) $r['cohort_assign_time']),
];
}
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
return self::metadata($ctx) + ['slot' => $slot, 'count' => \count($rows), 'rows' => $rows];
}
/** 保留部门下拉结构,补充二中心内医助;不影响复诊到期页对父类 deptOptions 的复用。 */
public static function deptOptions(): array
{
$options = self::cohortDeptOptions();
$deptSet = self::resolveDeptFilterSet(null);
if ($deptSet === []) {
return $options + ['assistants' => []];
}
$ids = Db::name('admin_dept')->whereIn('dept_id', array_keys($deptSet))->group('admin_id')->column('admin_id');
[$assistantDept] = self::buildAssistantDeptIndex($ids);
$admins = $ids === [] ? [] : Db::name('admin')->whereIn('id', $ids)->whereNull('delete_time')
->field(['id', 'name'])->order('name', 'asc')->select()->toArray();
$assistants = [];
foreach ($admins as $admin) {
$deptId = (int) ($assistantDept[(int) $admin['id']] ?? 0);
if (isset($deptSet[$deptId])) {
$assistants[] = ['id' => (int) $admin['id'], 'name' => (string) $admin['name'], 'dept_id' => $deptId];
}
}
return $options + ['assistants' => $assistants];
}
private static function cohortContext(array $params, string $type): array
{
$now = time();
[$start, $end, $startTs, $endTs] = RevisitCohortCalculator::dateRange($params, $now);
// 先按指派周期圈定诊单,再读取其完整时间线;避免小范围查询拉取全库历史日志。
$candidateQuery = Db::name('tcm_diagnosis_assign_log')->alias('lg')
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', '>=', $startTs)->where('lg.create_time', '<=', min($endTs, $now))
->where('lg.to_assistant_id', '>', 0)->where('lg.diagnosis_id', '>', 0);
$filterAid = (int) ($params['assistant_id'] ?? 0);
if ($filterAid > 0) {
$candidateQuery->where('lg.to_assistant_id', $filterAid);
}
$candidateIds = array_map('intval', $candidateQuery->group('lg.diagnosis_id')->column('lg.diagnosis_id'));
$logs = [];
foreach (array_chunk($candidateIds, 2000) as $chunk) {
$chunkLogs = Db::name('tcm_diagnosis_assign_log')->alias('lg')
->whereIn('lg.diagnosis_id', $chunk)->where('lg.create_time', '<=', $now)
->field(['lg.id', 'lg.diagnosis_id', 'lg.from_assistant_id', 'lg.to_assistant_id',
'lg.operator_admin_id', 'lg.create_time', 'lg.is_inherit'])
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
->select()->toArray();
array_push($logs, ...$chunkLogs);
}
$calc = RevisitCohortCalculator::compute(
$logs,
self::iterateOrderSeqRows($candidateIds, ['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']),
self::fetchRevisitSlotStartOffsetMap($candidateIds),
self::fetchRefundOrRejectDiagnosisSet($candidateIds),
self::fetchRollbackKeySet($startTs, min($endTs, $now)),
$startTs, $endTs, $now, $type
);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex(array_keys($calc['pairsByAssistant']));
$deptSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
foreach (array_keys($calc['pairsByAssistant']) as $aid) {
if (!isset($deptSet[$assistantDept[$aid] ?? 0]) || ($filterAid > 0 && $filterAid !== $aid)) {
unset($calc['pairsByAssistant'][$aid], $calc['ordersByAssistant'][$aid]);
}
}
return ['calc' => $calc, 'assistantDept' => $assistantDept, 'deptNames' => $deptNames,
'start_date' => $start, 'end_date' => $end, 'observed_until' => date('Y-m-d H:i:s', $now)];
}
private static function scopedAssistantIds(array $ctx, array $params): array
{
$aids = array_keys($ctx['calc']['pairsByAssistant']);
$scopeAid = (int) ($params['scope_assistant_id'] ?? 0);
if ($scopeAid > 0) {
return \in_array($scopeAid, $aids, true) ? [$scopeAid] : [];
}
if (isset($params['dept_id']) && $params['dept_id'] !== '') {
$deptId = (int) $params['dept_id'];
$aids = array_values(array_filter($aids, static fn (int $aid): bool => ($ctx['assistantDept'][$aid] ?? 0) === $deptId));
}
return $aids;
}
private static function metadata(array $ctx): array
{
return ['start_date' => $ctx['start_date'], 'end_date' => $ctx['end_date'],
'observed_until' => $ctx['observed_until'], 'cohort_type' => $ctx['calc']['cohort_type']];
}
}
@@ -108,9 +108,21 @@ class DiagnosisValidate extends BaseValidate
public function sceneEdit()
{
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'show_card', 'create_source']);
}
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'show_card', 'create_source'])
// 脱敏或被业务锁定的基本信息可省略,省略表示保留原值;显式提交仍需非空且格式有效。
->remove(['patient_name' => 'require', 'phone' => 'require', 'gender' => 'require', 'age' => 'require'])
->append(['patient_name' => 'requireWhenPresent', 'phone' => 'requireWhenPresent', 'gender' => 'requireWhenPresent', 'age' => 'requireWhenPresent']);
}
/** 编辑基本信息允许缺省,但不能用空值覆盖;require 前缀使 ThinkPHP 也校验 null/空串。 */
protected function requireWhenPresent($value, $rule, array $data = [], string $field = '')
{
if (!array_key_exists($field, $data) || $this->is($value, 'require')) {
return true;
}
return $this->message[$field . '.require'];
}
public function sceneId()
{
return $this->only(['id']);
@@ -950,7 +950,9 @@ class DifyChatService
bool $final = false
): void {
$buffer .= $chunk;
while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
// Keep CRLF atomic: otherwise regex backtracking can count a single CRLF
// as two line breaks and decode a multi-line event before it is complete.
while (preg_match('/(?>\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
$delimiter = $match[0][0];
$offset = $match[0][1];
$frame = substr($buffer, 0, $offset);
@@ -0,0 +1,32 @@
-- 保留原复诊率算法;新增独立「继承复诊接诊率」页面。
-- 幂等执行;新增页面和接口分别继承原页面对应权限,执行后需清理 AdminAuthCache。
SET @revisit_parent_id := (SELECT pid FROM zyt_system_menu WHERE perms = 'stats.revisitRate/overview' AND type = 'C' LIMIT 1);
INSERT INTO zyt_system_menu
(pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @revisit_parent_id, 'C', '继承复诊接诊率', '', 12, 'stats.revisitInheritRate/overview',
'/stats/revisit_inherit_rate', '/stats/revisit_inherit_rate/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @revisit_parent_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'stats.revisitInheritRate/overview');
SET @inherit_rate_id := (SELECT id FROM zyt_system_menu WHERE perms = 'stats.revisitInheritRate/overview' AND type = 'C' LIMIT 1);
INSERT INTO zyt_system_menu
(pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @inherit_rate_id, 'A', p.name, '', p.sort, p.perms, '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM (
SELECT '继承统计-部门医助选项' AS name, 1 AS sort, 'stats.revisitInheritRate/deptOptions' AS perms
UNION ALL SELECT '继承统计-患者明细', 2, 'stats.revisitInheritRate/assignLines'
UNION ALL SELECT '继承统计-订单明细', 3, 'stats.revisitInheritRate/visitOrderLines'
) p
WHERE @inherit_rate_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu m WHERE m.perms = p.perms);
INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id)
SELECT DISTINCT rm.role_id, target.id
FROM zyt_system_role_menu rm
INNER JOIN zyt_system_menu original ON original.id = rm.menu_id
INNER JOIN zyt_system_menu target ON target.perms = REPLACE(original.perms, 'stats.revisitRate/', 'stats.revisitInheritRate/')
WHERE original.perms IN ('stats.revisitRate/overview', 'stats.revisitRate/deptOptions',
'stats.revisitRate/assignLines', 'stats.revisitRate/visitOrderLines');
@@ -0,0 +1,32 @@
-- 原复诊接诊率页面和接口保持原算法,按指派周期统计使用独立菜单及权限。
-- 可重复执行;逐项继承原报表角色的对应权限,执行后刷新 AdminAuthCache。
SET @revisit_parent_id := (SELECT pid FROM zyt_system_menu WHERE perms = 'stats.revisitRate/overview' AND type = 'C' LIMIT 1);
INSERT INTO zyt_system_menu
(pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @revisit_parent_id, 'C', '按指派周期复诊率', '', 13, 'stats.revisitAssignRate/overview',
'/stats/revisit_assign_rate', '/stats/revisit_assign_rate/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @revisit_parent_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'stats.revisitAssignRate/overview');
SET @assign_rate_id := (SELECT id FROM zyt_system_menu WHERE perms = 'stats.revisitAssignRate/overview' AND type = 'C' LIMIT 1);
INSERT INTO zyt_system_menu
(pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @assign_rate_id, 'A', p.name, '', p.sort, p.perms, '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM (
SELECT '指派周期统计-部门医助选项' AS name, 1 AS sort, 'stats.revisitAssignRate/deptOptions' AS perms
UNION ALL SELECT '指派周期统计-患者明细', 2, 'stats.revisitAssignRate/assignLines'
UNION ALL SELECT '指派周期统计-订单明细', 3, 'stats.revisitAssignRate/visitOrderLines'
) p
WHERE @assign_rate_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu m WHERE m.perms = p.perms);
INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id)
SELECT DISTINCT rm.role_id, target.id
FROM zyt_system_role_menu rm
INNER JOIN zyt_system_menu original ON original.id = rm.menu_id
INNER JOIN zyt_system_menu target ON target.perms = REPLACE(original.perms, 'stats.revisitRate/', 'stats.revisitAssignRate/')
WHERE original.perms IN ('stats.revisitRate/overview', 'stats.revisitRate/deptOptions',
'stats.revisitRate/assignLines', 'stats.revisitRate/visitOrderLines');
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\validate\tcm\DiagnosisValidate;
$testApp = new think\App(); // Deliberately not initialized: no .env/config discovery.
$testLang = new think\Lang($testApp);
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
function diagnosisEditExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
// Install in-memory boundaries before loading the real validator and logic.
// No application bootstrap, credentials, database, or external services are used.
final class DiagnosisEditModelDouble
{
public static array $record;
public static array $lastUpdate = [];
public static int $updateCount = 0;
public static function reset(): void
{
self::$record = [
'id' => 42,
'patient_name' => '测试患者',
'id_card' => '110101199001010011',
'phone' => '13800138000',
'gender' => 1,
'age' => 36,
'remark' => '原备注',
];
self::$lastUpdate = [];
self::$updateCount = 0;
}
public static function findOrEmpty($id): object
{
return new class((int) $id !== 42) {
public function __construct(private bool $empty) {}
public function isEmpty(): bool { return $this->empty; }
};
}
public static function find($id): ?object
{
// The post-update TRTC lookup is deliberately empty, so no service runs.
return self::$updateCount === 0 && (int) $id === 42 ? (object) self::$record : null;
}
public static function update(array $params): void
{
self::$lastUpdate = $params;
self::$record = array_replace(self::$record, $params);
self::$updateCount++;
}
public static function where(...$args): object
{
// No duplicate patient exists in this isolated fixture.
return new class {
public function where(...$args): self { return $this; }
public function find() { return null; }
};
}
}
final class DiagnosisEditDbDouble
{
public static int $orderLookups = 0;
public static function name(string $table): object
{
diagnosisEditExpect($table === 'tcm_prescription_order', 'only the basic-info order lock queries storage');
self::$orderLookups++;
return new class {
public function where(...$args): self { return $this; }
public function whereNull(...$args): self { return $this; }
public function order(...$args): self { return $this; }
public function field(...$args): self { return $this; }
public function find(): array { return ['fulfillment_status' => 1]; }
};
}
}
final class DiagnosisEditAuthDouble
{
public static function getAuthByAdminId(int $adminId): array { return []; }
}
diagnosisEditExpect(class_alias(DiagnosisEditModelDouble::class, 'app\\common\\model\\tcm\\Diagnosis'), 'model double is installed');
diagnosisEditExpect(class_alias(DiagnosisEditDbDouble::class, 'think\\facade\\Db'), 'database double is installed');
diagnosisEditExpect(class_alias(DiagnosisEditAuthDouble::class, 'app\\adminapi\\logic\\auth\\AuthLogic'), 'permission double is installed');
function diagnosisEditCheck(array $payload, bool $expected, string $label, string $scene = 'edit'): void
{
$validator = (new DiagnosisValidate())->scene($scene);
$actual = $validator->check($payload);
diagnosisEditExpect($actual === $expected, $label . '; got: ' . json_encode($validator->getError(), JSON_UNESCAPED_UNICODE));
}
$partial = [
'id' => 42,
'diagnosis_type' => 'follow_up',
'local_hospital_name' => '测试医院',
'remark' => '新备注',
];
diagnosisEditCheck($partial, true, 'edit accepts omitted locked/basic/masked fields');
$basic = [
'patient_name' => '测试患者',
'phone' => '13800138000',
'gender' => 1,
'age' => 36,
];
diagnosisEditCheck($partial + $basic, true, 'edit still accepts full basic information');
foreach (array_keys($basic) as $field) {
diagnosisEditCheck($partial + array_diff_key($basic, [$field => true]), true, "edit permits omitted {$field} while other basic fields are supplied");
}
diagnosisEditCheck($partial + ['phone' => '13900139000'], true, 'edit accepts a valid supplied phone');
diagnosisEditCheck($partial + ['gender' => 0, 'age' => 0], true, 'zero gender and age remain valid');
foreach (['', null, '156****7862', '123', ' 13800138000 ', false, []] as $phone) {
diagnosisEditCheck($partial + ['phone' => $phone], false, 'edit rejects invalid explicit phone ' . json_encode($phone));
}
foreach (['patient_name', 'gender', 'age'] as $field) {
foreach (['', null] as $empty) {
diagnosisEditCheck($partial + [$field => $empty], false, "edit rejects explicitly empty {$field}");
}
}
diagnosisEditCheck($partial + ['patient_name' => str_repeat('患', 51)], false, 'name length remains validated');
diagnosisEditCheck($partial + ['gender' => 2], false, 'gender range remains validated');
diagnosisEditCheck($partial + ['age' => 151], false, 'age range remains validated');
diagnosisEditCheck($partial + ['age' => 'invalid'], false, 'age type remains validated');
diagnosisEditCheck(array_diff_key($partial, ['id' => true]), false, 'edit still requires diagnosis ID');
diagnosisEditCheck(array_replace($partial, ['id' => 404]), false, 'edit still requires an existing diagnosis');
foreach (['diagnosis_type', 'local_hospital_name'] as $field) {
diagnosisEditCheck(array_diff_key($partial, [$field => true]), false, "edit still requires unrelated {$field}");
}
$add = array_diff_key($partial + $basic, ['id' => true]);
diagnosisEditCheck($add, true, 'add accepts valid full payload', 'add');
foreach (array_keys($basic) as $field) {
diagnosisEditCheck(array_diff_key($add, [$field => true]), false, "add still requires {$field}", 'add');
}
foreach (['', null, '156****7862'] as $phone) {
diagnosisEditCheck(array_replace($add, ['phone' => $phone]), false, 'add rejects empty or masked phone', 'add');
}
$doctor = ['admin_id' => 7, 'root' => 0];
DiagnosisEditModelDouble::reset();
$before = DiagnosisEditModelDouble::$record;
diagnosisEditExpect(DiagnosisLogic::edit($partial, $doctor), 'locked doctor can save clinical edits without basic fields');
diagnosisEditExpect(DiagnosisEditDbDouble::$orderLookups > 0, 'the existing pending-order lock is evaluated');
diagnosisEditExpect(DiagnosisEditModelDouble::$record['remark'] === '新备注', 'clinical edit reaches storage');
foreach (['patient_name', 'id_card', 'phone', 'gender', 'age'] as $field) {
diagnosisEditExpect(!array_key_exists($field, DiagnosisEditModelDouble::$lastUpdate), "update does not synthesize {$field}");
diagnosisEditExpect(DiagnosisEditModelDouble::$record[$field] === $before[$field], "omitted {$field} remains unchanged");
}
foreach (['patient_name' => '另一个患者', 'id_card' => '110101199001010029', 'phone' => '13900139000', 'gender' => 0, 'age' => 37] as $field => $value) {
DiagnosisEditModelDouble::reset();
diagnosisEditExpect(!DiagnosisLogic::edit($partial + [$field => $value], $doctor), "pending order still locks {$field}");
diagnosisEditExpect(DiagnosisLogic::getError() === '最近业务订单未完成,无法修改患者基本信息', 'basic-info lock keeps its error');
diagnosisEditExpect(DiagnosisEditModelDouble::$updateCount === 0, 'denied change never reaches storage');
}
DiagnosisEditModelDouble::reset();
diagnosisEditExpect(DiagnosisLogic::edit($partial + $basic, $doctor), 'unchanged supplied basic fields still pass the lock');
DiagnosisEditModelDouble::reset();
diagnosisEditExpect(DiagnosisLogic::edit($partial + ['phone' => '13900139000'], ['admin_id' => 1, 'root' => 1]), 'existing authorized basic-info editing remains available');
diagnosisEditExpect(DiagnosisEditModelDouble::$record['phone'] === '13900139000', 'valid authorized phone edit reaches storage');
echo "Diagnosis edit-scene validation and masked/basic-field preservation: OK\n";
@@ -172,6 +172,31 @@ difyStreamExpect($decodedOpenAi['content'] === 'A中', 'OpenAI decoder handles a
difyStreamExpect($decodedOpenAi['deltas'] === ['A', '中'], 'OpenAI decoder emits choices delta content only');
difyStreamExpect($decodedOpenAi['finished'] === true, 'OpenAI [DONE] terminates parsing');
$pendingBuffer = '';
$pendingState = callDifyStreamPrivate('newStreamState', []);
$liveDeltas = [];
$liveCallback = static function (string $delta) use (&$liveDeltas): void {
$liveDeltas[] = $delta;
};
callDifyStreamPrivate('consumeStreamBytes', [
'dify', &$pendingBuffer, "data: {\"event\":\"message\",\"answer\":\"首字\"}\r\n", &$pendingState, $liveCallback,
]);
difyStreamExpect($liveDeltas === [], 'a data line alone is not a complete SSE event');
callDifyStreamPrivate('consumeStreamBytes', ['dify', &$pendingBuffer, "\r\n", &$pendingState, $liveCallback]);
difyStreamExpect($liveDeltas === ['首字'], 'a complete delta is delivered before upstream EOF or a terminal event');
difyStreamExpect($pendingState['finished'] === false, 'incremental delivery does not wait for message_end');
$multilineWire = "data: {\"event\":\"message\",\r\n"
. "data: \"answer\":\"逐帧\"}\r\n\r\n"
. "data: {\"event\":\"message_end\"}\r\n\r\n";
foreach ([1, 2, strlen($multilineWire)] as $chunkSize) {
$multiline = callDifyStreamPrivate('decodeStreamChunks', ['dify', str_split($multilineWire, $chunkSize)]);
difyStreamExpect(
$multiline['deltas'] === ['逐帧'] && $multiline['finished'] === true,
'CRLF multiline data stays one event across arbitrary transport boundaries'
);
}
$malformed = callDifyStreamPrivate('decodeStreamChunks', [
'dify',
["data: not-json\n\n", "data: {\"event\":\"error\",\"message\":\"secret-upstream-body\"}\n\n"],
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace app\common\service {
function curl_init(): \stdClass
{
return (object) ['options' => [], 'errno' => 0];
}
function curl_setopt_array(\stdClass $handle, array $options): bool
{
$handle->options = $options;
return true;
}
function curl_exec(\stdClass $handle): string|bool
{
$GLOBALS['streamDeliveryExecuting'] = true;
$options = $handle->options;
$options[CURLOPT_HEADERFUNCTION]($handle, 'HTTP/1.1 ' . $GLOBALS['streamDeliveryStatus'] . " Fixture\r\n");
foreach ($GLOBALS['streamDeliveryChunks'] as [$chunk, $expectedDeltas]) {
$written = $options[CURLOPT_WRITEFUNCTION]($handle, $chunk);
\deliveryExpect(count($GLOBALS['streamDeliveryDeltas']) === $expectedDeltas,
'each complete upstream frame reaches the consumer before the next network read');
if ($written !== strlen($chunk)) {
$handle->errno = CURLE_WRITE_ERROR;
break;
}
}
$GLOBALS['streamDeliveryExecuting'] = false;
return $handle->errno === 0 ? '' : false;
}
function curl_errno(\stdClass $handle): int { return $handle->errno; }
function curl_getinfo(\stdClass $handle, int $option): int { return $GLOBALS['streamDeliveryStatus']; }
function curl_close(\stdClass $handle): void {}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
function deliveryExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
function deliverFixture(string $protocol, array $chunks, int $status = 200, bool $accept = true): array
{
$GLOBALS['streamDeliveryStatus'] = $status;
$GLOBALS['streamDeliveryChunks'] = $chunks;
$GLOBALS['streamDeliveryDeltas'] = [];
$method = (new ReflectionClass(DifyChatService::class))->getMethod('sendStreamRequest');
return $method->invoke(null, $protocol, 'https://offline.example.test', [], 'offline-fixture', 30,
static function (string $delta) use ($accept): bool {
deliveryExpect($GLOBALS['streamDeliveryExecuting'], 'delta callback runs during curl_exec, before transfer completion');
$GLOBALS['streamDeliveryDeltas'][] = $delta;
return $accept;
}, null);
}
$dify = deliverFixture('dify', [
["data: {\"event\":\"message\",\"answer\":\"首\"}\r\n", 0],
["\r\n", 1],
["data: {\"event\":\"message\",\r\ndata: \"answer\":\"字\"}\r\n\r\n", 2],
["data: {\"event\":\"message_end\"}\r\n\r\n", 2],
]);
deliveryExpect($dify['content'] === '首字' && $dify['finished'], 'Dify streams complete frames in order');
$openAi = deliverFixture('openai', [
["data: {\"choices\":[{\"delta\":{\"content\":\"A\"}}]}\n\n", 1],
["data: {\"choices\":[{\"delta\":{\"content\":\"B\"}}]}\n\n", 2],
["data: [DONE]\n\n", 2],
]);
deliveryExpect($openAi['content'] === 'AB' && $openAi['finished'], 'OpenAI streams complete frames in order');
$rejected = deliverFixture('dify', [
["data: {\"event\":\"message\",\"answer\":\"private upstream error\"}\n\n", 0],
], 401);
deliveryExpect(!$rejected['emitted'] && $rejected['content'] === '', 'HTTP errors never become downstream text');
$aborted = deliverFixture('dify', [
["data: {\"event\":\"message\",\"answer\":\"first\"}\n\n", 1],
["data: {\"event\":\"message\",\"answer\":\"late\"}\n\n", 2],
], 200, false);
deliveryExpect($aborted['callback_error'] && $GLOBALS['streamDeliveryDeltas'] === ['first'],
'consumer refusal stops the current transfer before another delta');
echo "Dify chat stream delivery: OK\n";
}
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
/** php server/tests/RevisitCohortCalculatorTest.php:日期队列 / 继承重算,不连接数据库。 */
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\stats\RevisitCohortCalculator as C;
use app\adminapi\logic\stats\RevisitRateCalculator as Legacy;
date_default_timezone_set('Asia/Shanghai');
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
$t = static fn (string $date): int => (int) strtotime($date);
$logId = 0;
$orderId = 0;
$log = static function (int $did, int $from, int $to, string $date, int $inherit = 0) use (&$logId, $t): array {
return ['id' => ++$logId, 'diagnosis_id' => $did, 'from_assistant_id' => $from,
'to_assistant_id' => $to, 'create_time' => $t($date), 'is_inherit' => $inherit, 'operator_admin_id' => 7];
};
$order = static function (int $did, string $date) use (&$orderId, $t): array {
return ['id' => ++$orderId, 'diagnosis_id' => $did, 'create_time' => $t($date),
'order_no' => 'TEST-' . $orderId, 'amount' => 100, 'creator_id' => 1];
};
$sort = static function (array $rows): array {
usort($rows, static fn (array $a, array $b): int =>
[$a['diagnosis_id'], $a['create_time'], $a['id']] <=> [$b['diagnosis_id'], $b['create_time'], $b['id']]);
return $rows;
};
$start = $t('2026-09-01 00:00:00');
$end = $t('2026-10-30 23:59:59');
$now = $t('2026-12-01 12:00:00');
$run = static fn (array $logs, array $orders, string $type, array $offsets = [], array $reject = [], array $rollback = []): array =>
C::compute($sort($logs), $sort($orders), $offsets, $reject, $rollback, $start, $end, $now, $type);
$pack = static fn (array $calc, array $aids): array => C::metricPack($calc, $aids, [2, 3, 4]);
// 新指派筛选的是指派日期:周期前指派的患者不混入分子,周期后的复诊累计至查询当天。
$logs = [
$log(10, 0, 1, '2026-09-01 00:00:00'),
$log(11, 0, 1, '2026-08-31 23:59:59'),
$log(12, 0, 1, '2026-10-30 23:59:59'),
$log(13, 0, 1, '2026-10-31 00:00:00'),
];
$orders = [
$order(10, '2026-09-02'), $order(10, '2026-11-15'), $order(10, '2026-12-01 12:00:00'), $order(10, '2026-12-02'),
$order(11, '2026-09-03'), $order(11, '2026-10-01'),
$order(12, '2026-11-01'), $order(12, '2026-11-20'),
$order(13, '2026-11-01'), $order(13, '2026-11-20'),
];
$calc = $run($logs, $orders, 'new');
$p = $pack($calc, [1]);
expect($p['assigned_count'] === 2 && $p['inherit_count'] === 0, '范围两端均包含,前后一天均排除');
expect($p['visit2_count'] === 2 && $p['visit2_rate'] === 100.0, '11 月复诊计入9~10月指派队列');
expect($p['visit3_count'] === 1 && $p['visit4_count'] === 0, '查询同刻订单包含,未来订单不计入');
expect($p['visit_all_count'] === 3 && $p['visit_all_rate'] === 150.0, '合计是人均复诊单次,可以超过100%');
expect(count(C::scopedOrders($calc, [1], 2)[2]) === $p['visit2_count'], '二诊明细数量等于二诊分子');
expect($pack($calc, [99])['visit2_rate'] === null, '无患者分母为0,率为null');
// 继承前有三笔订单和偏移:继承后首单仍算二诊,原医助只领取转出前订单。
$logs = [$log(20, 0, 1, '2026-09-01'), $log(20, 1, 2, '2026-10-10 10:00:00', 1)];
$orders = [$order(20, '2026-09-02'), $order(20, '2026-09-03'), $order(20, '2026-09-04'),
$order(20, '2026-10-10 10:00:00'), $order(20, '2026-11-20')];
$new = $run($logs, $orders, 'new', [20 => 5]);
$inherited = $run($logs, $orders, 'inherit', [20 => 5]);
expect(array_keys($new['pairsByAssistant']) === [1] && array_keys($inherited['pairsByAssistant']) === [2], '新指派、继承独立入组');
expect(!isset($new['ordersByAssistant'][2]), '继承订单不得混入新指派分子');
$p = $pack($inherited, [2]);
expect($p['assigned_count'] === 0 && $p['inherit_count'] === 1 && $p['visit2_count'] === 1, '继承首单计二诊');
expect($p['visit3_count'] === 1 && $p['visit2_den'] === 1 && $p['visit3_den'] === 1, '继承次单计三诊,各列分母只含继承患者');
$first = C::scopedOrders($inherited, [2], 2)[2][20];
expect($first['create_time'] === $t('2026-10-10 10:00:00') && $first['global_visit_slot'] === 9, '同秒归新医助且不受全局诊次/偏移影响');
expect($first['cohort_assign_time'] === $t('2026-10-10 10:00:00'), '订单明细带实际承接时间');
expect($pack($new, [1])['visit_all_count'] === 3 && !isset($new['ordersByAssistant'][1][9]), '新指派保留全局诊次偏移,只计转出前3单');
// 转派、释放、再次继承、同一医助既新指派又继承:不混算,继承每段重新起算。
$logs = [
$log(30, 0, 1, '2026-09-01'),
$log(30, 1, 2, '2026-09-10', 1),
$log(30, 2, 2, '2026-09-11', 1), // 自指派忽略,不重置
$log(30, 2, 0, '2026-09-15'),
$log(30, 0, 2, '2026-09-20', 1),
$log(30, 2, 1, '2026-10-01', 1),
];
$orders = [$order(30, '2026-09-02'), $order(30, '2026-09-10'), $order(30, '2026-09-12'),
$order(30, '2026-09-16'), $order(30, '2026-09-21'), $order(30, '2026-09-22'),
$order(30, '2026-10-02')];
$calc = $run($logs, $orders, 'inherit');
expect($calc['pairsByAssistant'][2][30]['assign_count'] === 2, '多次真实继承计次数,自指派不计');
expect($calc['pairsByAssistant'][1][30]['assign_count'] === 1, '同一医助既新指派又继承,继承独立统计');
$p2 = $pack($calc, [2]);
expect($p2['inherit_count'] === 1 && $p2['visit2_count'] === 1 && $p2['visit3_count'] === 1, '重复继承患者分母、同一诊次分子均去重');
expect(C::scopedOrders($calc, [2], 3)[3][30]['create_time'] === $t('2026-09-12'), '自指派不把后续订单重置到二诊');
$total = $pack($calc, [1, 2]);
expect($total['inherit_count'] === 1 && $total['visit2_count'] === 1 && $total['visit2_rate'] === 100.0, '部门/合计按患者去重,不能把多医助同患者二诊重复相加');
expect($pack($calc, [1])['visit3_count'] === 0, '按医助筛选时不能混入另一医助三诊');
expect($total['visit_all_count'] === array_sum(array_map('count', C::scopedOrders($calc, [1, 2]))), '合计订单明细与去重分子逐项对账');
// 周期后重新继承的订单不应继续计入旧队列;释放也会终止归属。
$logs = [$log(40, 0, 1, '2026-09-01', 1), $log(40, 1, 0, '2026-10-01'),
$log(40, 0, 1, '2026-11-01', 1)];
$calc = $run($logs, [$order(40, '2026-09-02'), $order(40, '2026-10-02'), $order(40, '2026-11-02')], 'inherit');
expect($pack($calc, [1])['visit_all_count'] === 1, '释放后及区间外再次承接的订单不归旧队列');
// 写入侧从待分配重新指派给原医助时会回填 from,不能误判为自指派。
foreach (['new' => 0, 'inherit' => 1] as $type => $isInherit) {
$logs = [$log(41, 0, 1, '2026-08-01'), $log(41, 1, 0, '2026-08-10'),
$log(41, 1, 1, '2026-09-01', $isInherit)];
$calc = $run($logs, [$order(41, '2026-08-02'), $order(41, '2026-09-02')], $type);
expect($pack($calc, [1])['assigned_total_count'] === 1 && $pack($calc, [1])['visit2_count'] === 1,
'释放后同医助重新承接正常入组并统计:' . $type);
}
// 回退 is_inherit=0,通过回退键识别继承;拒收/退款剔除同时作用于分子、分母。
$rollbackTime = $t('2026-09-15');
$logs = [$log(50, 0, 1, '2026-09-01'), $log(50, 1, 2, '2026-09-15')];
$orders = [$order(50, '2026-09-02'), $order(50, '2026-09-16')];
$keys = [Legacy::rollbackKey(50, $rollbackTime, 7) => true];
expect(!isset($run($logs, $orders, 'new', [], [], $keys)['pairsByAssistant'][2]), '历史回退不能计入新指派');
expect($pack($run($logs, $orders, 'inherit', [], [], $keys), [2])['visit2_count'] === 1, '历史回退首单按继承二诊');
$rejected = $run($logs, $orders, 'inherit', [], [50 => true], $keys);
expect($rejected['pairsByAssistant'] === [] && $rejected['ordersByAssistant'] === [], '拒收/退款诊单完全剔除,无孤立分子');
// 同秒多次转派按日志 id 升序,订单最终归最新承接事件;两笔同秒有效订单按 id 编号。
$logs = [$log(60, 0, 1, '2026-09-01'), $log(60, 1, 2, '2026-09-10', 1), $log(60, 2, 3, '2026-09-10', 1)];
$calc = $run($logs, [$order(60, '2026-09-10'), $order(60, '2026-09-10')], 'inherit');
expect($pack($calc, [2])['visit_all_count'] === 0 && $pack($calc, [3])['visit_all_count'] === 2, '同秒转派和订单按稳定顺序计算');
// 日期解析支持跨月与未来结束日,不把月末或非法日期自动纠正。
[$s, $e, $sTs, $eTs] = C::dateRange(['start_date' => '2026-09-01', 'end_date' => '2026-10-30'], $t('2026-10-16'));
expect($s === '2026-09-01' && $e === '2026-10-30' && $sTs === $start && $eTs === $end, '跨月范围保留未来结束日');
expect(array_slice(C::dateRange([], $t('2026-10-16')), 0, 2) === ['2026-10-01', '2026-10-16'], '默认当月首日至当天');
expect(array_slice(C::dateRange(['month' => '2024-02'], $now), 0, 2) === ['2024-02-01', '2024-02-29'], '兼容旧 month 参数和闰月');
foreach ([['2026-02-30', '2026-03-01'], ['2026-09-10', '2026-09-01'], ['', '2026-10-30'], ['2026-9-01', '2026-10-30']] as [$s, $e]) {
$threw = false;
try {
C::dateRange(['start_date' => $s, 'end_date' => $e], $now);
} catch (InvalidArgumentException) {
$threw = true;
}
expect($threw, '拒绝非法/缺失/倒置日期:' . $s . ' - ' . $e);
}
echo "RevisitCohortCalculatorTest OK ({$checks} checks)\n";
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
/** 仅测试显式指定的本机临时 MySQL;不 initialize 应用、不读取 .env。 */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\stats\RevisitCohortLogic as L;
use app\adminapi\logic\stats\RevisitRateLogic as Original;
use app\adminapi\controller\stats\RevisitRateController;
use app\adminapi\controller\stats\RevisitAssignRateController;
use app\adminapi\controller\stats\RevisitInheritRateController;
use think\Container;
$port = (int) getenv('REVISIT_COHORT_TEST_MYSQL_PORT');
if ($port < 1024 || $port === 3306 || getenv('REVISIT_COHORT_TEST_DISPOSABLE') !== '1') {
throw new RuntimeException('Set REVISIT_COHORT_TEST_DISPOSABLE=1 and an isolated loopback MySQL port');
}
date_default_timezone_set('Asia/Shanghai');
$database = 'revisit_cohort_' . bin2hex(random_bytes(6));
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
$pdo->exec("USE `{$database}`");
new think\App();
$manager = new think\DbManager();
$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port, 'database' => $database,
'username' => 'root', 'password' => '', 'charset' => 'utf8mb4', 'prefix' => 'zyt_',
'fields_strict' => true, 'trigger_sql' => false,
]]]);
Container::getInstance()->instance('think\DbManager', $manager);
Container::getInstance()->instance('config', new think\Config());
$checks = 0;
$expect = static function (bool $condition, string $message) use (&$checks): void {
if (!$condition) {
throw new RuntimeException('FAIL: ' . $message);
}
$checks++;
};
try {
$pdo->exec('CREATE TABLE zyt_dept(id INT PRIMARY KEY,pid INT,name VARCHAR(50),sort INT DEFAULT 0,delete_time INT NULL)');
$pdo->exec("INSERT INTO zyt_dept(id,pid,name) VALUES(10,0,'一中心'),(11,10,'一组'),(20,0,'二中心'),(21,20,'二中心甲组'),(22,20,'乙组')");
$pdo->exec('CREATE TABLE zyt_admin(id INT PRIMARY KEY,name VARCHAR(50),delete_time INT NULL)');
$pdo->exec("INSERT INTO zyt_admin VALUES(1,'医助甲',NULL),(2,'医助乙',NULL),(3,'一中心员工',NULL),(4,'已删除',1)");
$pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT,PRIMARY KEY(admin_id,dept_id))');
$pdo->exec('INSERT INTO zyt_admin_dept VALUES(1,21),(2,22),(3,11),(4,21)');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis(id INT PRIMARY KEY,patient_name VARCHAR(50),phone VARCHAR(20),revisit_slot_start_offset INT DEFAULT 0,delete_time INT NULL)');
for ($id = 10; $id <= 17; $id++) {
$pdo->prepare('INSERT INTO zyt_tcm_diagnosis(id,patient_name,phone,delete_time) VALUES(?,?,?,?)')
->execute([$id, '虚构患者' . $id, '13800000000', $id === 16 ? 1 : null]);
}
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis_assign_log(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,from_assistant_id INT,to_assistant_id INT,operator_admin_id INT DEFAULT 7,create_time INT,is_inherit INT DEFAULT 0,inherit_visit_slot INT DEFAULT 0)');
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis_auto_assign_log(diagnosis_id INT,rollback_time INT,rollback_admin_id INT)');
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order(id INT PRIMARY KEY AUTO_INCREMENT,order_no VARCHAR(50),diagnosis_id INT,create_time INT,amount DECIMAL(10,2) DEFAULT 100,creator_id INT DEFAULT 1,fulfillment_status INT NULL,delete_time INT NULL)');
$lg = $pdo->prepare('INSERT INTO zyt_tcm_diagnosis_assign_log(diagnosis_id,from_assistant_id,to_assistant_id,create_time,is_inherit) VALUES(?,?,?,?,?)');
foreach ([[10,0,1,'2026-09-01',0],[10,1,2,'2026-09-20',1],[11,0,1,'2026-08-01',0],
[12,0,2,'2026-09-03',0],[13,0,3,'2026-09-03',0],[14,0,1,'2026-09-04',0],
[15,0,1,'2026-08-01',0],[15,1,2,'2026-09-15',1],[16,0,1,'2026-09-01',0],
[17,1,1,'2026-09-01',0]] as [$did,$from,$to,$date,$inherit]) {
$lg->execute([$did,$from,$to,strtotime($date),$inherit]);
}
$o = $pdo->prepare('INSERT INTO zyt_tcm_prescription_order(order_no,diagnosis_id,create_time,fulfillment_status) VALUES(?,?,?,?)');
foreach ([[10,'2026-09-02',3],[10,'2026-09-05',3],[10,'2026-10-01',3],[10,'2026-10-05',3],
[11,'2026-08-02',3],[11,'2026-10-01',3],[13,'2026-09-05',3],[13,'2026-09-06',3],
[14,'2026-09-05',3],[14,'2026-09-06',9],[15,'2026-08-02',3],[15,'2026-08-03',3],
[15,'2026-08-04',3],[15,'2026-09-16',3],[15,'2026-09-17',4],[15,'2026-10-02',3]] as $i => [$did,$date,$status]) {
$o->execute(['SYNTHETIC-' . $i,$did,strtotime($date),$status]);
}
$q = ['start_date' => '2026-09-01', 'end_date' => '2026-09-30'];
$invoke = static function (string $class, string $action, array $params): array {
$controller = (new ReflectionClass($class))->newInstanceWithoutConstructor();
$request = (new think\Request())->withGet($params);
$property = (new ReflectionClass(app\BaseController::class))->getProperty('request');
$property->setAccessible(true);
$property->setValue($controller, $request);
return $controller->$action()->getData()['data'];
};
foreach (['exclude', 'slot'] as $mode) {
$legacyParams = ['month' => '2026-09', 'inherit_mode' => $mode];
foreach (['overview', 'assignLines', 'visitOrderLines'] as $action) {
$params = $legacyParams + ['slot' => 0];
$expect($invoke(RevisitRateController::class, $action, $params) === Original::$action($params),
'原Controller实际响应与原算法一致:' . $mode . '/' . $action);
}
}
$legacyBaseline = Original::overview(['month' => '2026-09', 'inherit_mode' => 'exclude']);
$expect($legacyBaseline['total']['assigned_count'] === 3 && $legacyBaseline['total']['visit4_count'] === 1,
'原算法保留原自指派分母及当月继承订单分子规则');
$expect($invoke(RevisitRateController::class, 'deptOptions', []) === Original::deptOptions(), '原部门下拉契约不增加新报表字段');
$new = L::overview($q);
$expect($invoke(RevisitAssignRateController::class, 'overview', $q + ['cohort_type' => 'inherit'])['cohort_type'] === 'new', '新指派入口固定new且使用独立Controller');
$expect($invoke(RevisitInheritRateController::class, 'overview', $q + ['cohort_type' => 'new'])['cohort_type'] === 'inherit', '继承入口固定inherit');
$expect(Original::overview(['month' => '2026-09', 'inherit_mode' => 'exclude']) === $legacyBaseline,
'查询新报表不改变原报表响应');
$expect($new['total']['assigned_count'] === 2 && $new['total']['visit2_count'] === 1, '只圈定周期内新指派,排除区间外、继承、其他中心、自指派、软删、拒收');
$expect($new['total']['visit2_rate'] === 50.0 && $new['total']['inherit_count'] === 0, '新指派接诊率仅用新指派分母');
$inherit = L::overview($q, 'inherit');
$expect($inherit['total']['inherit_count'] === 2 && $inherit['total']['visit2_count'] === 2 && $inherit['total']['visit3_count'] === 2, '继承首单二诊、次单三诊,累计周期后订单且排除取消单');
$expect(L::overview($q + ['assistant_id' => 2])['total']['assigned_count'] === 1, '医助筛选作用于汇总');
$expect(L::overview($q + ['assistant_id' => 2])['total']['visit2_count'] === 0, '医助名下继承成交不进入其新指派分子');
$expect(L::overview($q + ['dept_ids' => '21'])['total']['assigned_count'] === 1, '部门子树过滤');
$expect(L::overview($q + ['dept_ids' => '20'])['total'] === $new['total'], '二中心父级包含各组');
$expect(L::overview($q + ['dept_ids' => '10'])['rows'] === [], '不能越过二中心范围');
$expect(L::overview($q + ['assistant_id' => 3])['rows'] === [], '其他中心医助不能绕过部门范围');
$expect(L::assignLines($q)['count'] === $new['total']['assigned_count'], '新指派患者明细与分母对账');
$expect(L::visitOrderLines($q + ['slot' => 2])['count'] === $new['total']['visit2_count'], '新指派订单明细与分子对账');
$expect(L::assignLines($q, 'inherit')['count'] === $inherit['total']['inherit_count'], '继承患者明细与分母对账');
$expect(L::visitOrderLines($q + ['slot' => 0], 'inherit')['count'] === $inherit['total']['visit_all_count'], '继承合计订单明细与汇总对账');
$expect(L::assignLines($q + ['assistant_id' => 2, 'dept_id' => 21])['count'] === 0, '下钻部门必须与页面医助取交集');
$expect(L::visitOrderLines($q + ['assistant_id' => 2, 'scope_assistant_id' => 1, 'slot' => 0])['count'] === 0, '下钻不能覆盖页面医助筛选');
$options = L::deptOptions();
$optionIds = array_column($options['assistants'], 'id');
sort($optionIds);
$expect($optionIds === [1, 2], '下拉排除其他中心及已删除账号');
$expect(count($options['rows']) === 3, '部门选项仍是二中心完整子树');
foreach ([18 => 0, 19 => 1] as $did => $isInherit) {
$pdo->prepare('INSERT INTO zyt_tcm_diagnosis(id,patient_name,phone) VALUES(?,?,?)')->execute([$did, '返还患者' . $did, '13800000000']);
$lg->execute([$did,0,1,strtotime('2026-08-01'),0]);
$lg->execute([$did,1,0,strtotime('2026-08-10'),0]);
$lg->execute([$did,1,1,strtotime('2026-09-01'),$isInherit]);
$o->execute(['RETURN-OLD-' . $did,$did,strtotime('2026-08-02'),3]);
$o->execute(['RETURN-NEW-' . $did,$did,strtotime('2026-09-02'),3]);
}
$expect(L::overview($q)['total']['assigned_count'] === 3 && L::overview($q)['total']['visit2_count'] === 2, '释放后重新新指派给原医助正常统计');
$expect(L::overview($q, 'inherit')['total']['inherit_count'] === 3 && L::overview($q, 'inherit')['total']['visit2_count'] === 3, '释放后重新继承给原医助正常统计');
// 在实际 MySQL 5.7 验证菜单脚本、幂等性与逐权限继承。
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY AUTO_INCREMENT,pid INT,type VARCHAR(1),name VARCHAR(80),icon VARCHAR(80),sort INT,perms VARCHAR(120),paths VARCHAR(120),component VARCHAR(120),selected VARCHAR(120),params VARCHAR(120),is_cache INT,is_show INT,is_disable INT,create_time INT,update_time INT)');
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT,PRIMARY KEY(role_id,menu_id))');
foreach (['overview','deptOptions','assignLines','visitOrderLines'] as $i => $action) {
$pdo->prepare('INSERT INTO zyt_system_menu(pid,type,name,perms) VALUES(100,?,?,?)')
->execute([$i === 0 ? 'C' : 'A', $action, 'stats.revisitRate/' . $action]);
}
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(9,1),(9,2),(9,3)');
$originalMenus = $pdo->query('SELECT * FROM zyt_system_menu ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$migration = file_get_contents(dirname(__DIR__) . '/sql/1.9.20261016/01_add_revisit_inherit_rate_menu.sql');
$migration = preg_replace('/^--.*$/m', '', $migration);
for ($repeat = 0; $repeat < 2; $repeat++) {
foreach (explode(';', $migration) as $sql) {
if (trim($sql) !== '') { $pdo->exec($sql); }
}
}
$expect((int) $pdo->query("SELECT COUNT(*) FROM zyt_system_menu WHERE perms LIKE 'stats.revisitInheritRate/%'")->fetchColumn() === 4, '迁移可重复执行,页面及3个接口各登记一次');
$expect((int) $pdo->query('SELECT COUNT(*) FROM zyt_system_role_menu WHERE role_id=9')->fetchColumn() === 6, '仅继承原有的3项对应权限');
$expect((int) $pdo->query("SELECT COUNT(*) FROM zyt_system_role_menu rm JOIN zyt_system_menu m ON m.id=rm.menu_id WHERE rm.role_id=9 AND m.perms='stats.revisitInheritRate/visitOrderLines'")->fetchColumn() === 0, '不扩大原角色明细权限');
$assignMigration = preg_replace('/^--.*$/m', '', file_get_contents(dirname(__DIR__) . '/sql/1.9.20261017/01_add_revisit_assign_rate_menu.sql'));
for ($repeat = 0; $repeat < 2; $repeat++) {
foreach (explode(';', $assignMigration) as $sql) {
if (trim($sql) !== '') { $pdo->exec($sql); }
}
}
$expect((int) $pdo->query("SELECT COUNT(*) FROM zyt_system_menu WHERE perms LIKE 'stats.revisitAssignRate/%'")->fetchColumn() === 4, '独立指派周期页面及3个接口幂等登记');
$expect((int) $pdo->query('SELECT COUNT(*) FROM zyt_system_role_menu WHERE role_id=9')->fetchColumn() === 9, '两个独立报表均逐权限继承原角色授权');
$expect((int) $pdo->query("SELECT COUNT(*) FROM zyt_system_role_menu rm JOIN zyt_system_menu m ON m.id=rm.menu_id WHERE rm.role_id=9 AND m.perms='stats.revisitAssignRate/visitOrderLines'")->fetchColumn() === 0, '独立指派周期接口不扩大订单明细权限');
$expect($pdo->query('SELECT * FROM zyt_system_menu WHERE id<=4 ORDER BY id')->fetchAll(PDO::FETCH_ASSOC) === $originalMenus, '新增菜单不会改写原菜单及权限记录');
echo "RevisitCohortDatabaseTest OK ({$checks} checks)\n";
} finally {
$pdo->exec("DROP DATABASE `{$database}`");
}