更新
This commit is contained in:
@@ -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";
|
||||
@@ -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}`");
|
||||
}
|
||||
Reference in New Issue
Block a user