Merge branch 'master-10-146'
This commit is contained in:
@@ -6,10 +6,32 @@ namespace app\adminapi\controller\tcm;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\tcm\PrescriptionAiLogic;
|
||||
use app\common\service\prescriptionai\PrescriptionAiVisibility;
|
||||
use DomainException;
|
||||
|
||||
final class PrescriptionAiController extends BaseAdminController
|
||||
{
|
||||
public function capabilities()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::capabilities($this->adminId));
|
||||
}
|
||||
|
||||
public function getVisibilityConfig()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::getConfig($this->adminId));
|
||||
}
|
||||
|
||||
public function setVisibilityConfig()
|
||||
{
|
||||
return $this->handle(true, ['role_ids', 'admin_ids'],
|
||||
fn (array $p): array => PrescriptionAiVisibility::setConfig($p, $this->adminId));
|
||||
}
|
||||
|
||||
public function visibilityOptions()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::options($this->adminId));
|
||||
}
|
||||
|
||||
public function statuses()
|
||||
{
|
||||
return $this->handle(false, ['ids'], function (array $p): array {
|
||||
|
||||
@@ -235,7 +235,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
|
||||
* 任意履约状态下修改主辅方服用参数(浓缩水丸袋数、次数、开立天数)及订单服用天数,并记录日志
|
||||
*/
|
||||
public function patchPrescriptionUsage()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
|
||||
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
|
||||
use app\common\service\prescriptionai\PrescriptionAiStatistics;
|
||||
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
|
||||
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
@@ -22,15 +23,16 @@ final class PrescriptionAiLogic
|
||||
public static function statuses(array $ids, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('statuses', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
if (!Store::enabled()) {
|
||||
return ['enabled' => false, 'items' => []];
|
||||
return ['enabled' => false, 'agreement_visible' => $agreementVisible, 'items' => []];
|
||||
}
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
if (count($ids) > 100 || array_filter($ids, static fn ($id): bool => $id <= 0)) {
|
||||
throw new DomainException('最多查询100个有效处方');
|
||||
}
|
||||
if ($ids === []) {
|
||||
return ['enabled' => true, 'items' => []];
|
||||
return ['enabled' => true, 'agreement_visible' => $agreementVisible, 'items' => []];
|
||||
}
|
||||
$subjects = Db::name('prescription_ai_subject')->whereIn('prescription_id', $ids)->column('latest_batch_id', 'prescription_id');
|
||||
$batches = $subjects ? Db::name('prescription_ai_batch')->whereIn('id', array_values($subjects))->select()->toArray() : [];
|
||||
@@ -50,18 +52,20 @@ final class PrescriptionAiLogic
|
||||
// Do not reveal that a report with a broader source scope exists.
|
||||
continue;
|
||||
}
|
||||
$items[] = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
|
||||
$item = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
|
||||
'prescription_id' => $id, 'batch_id' => null,
|
||||
'status' => (int) ($rx['is_system_auto'] ?? 0) === 1 ? 'blank' : 'not_generated',
|
||||
'coverage_status' => 'pending', 'validity' => 'current', 'comparison_type' => 'unavailable', 'models' => [],
|
||||
];
|
||||
$items[] = Visibility::projectBatch($item, $agreementVisible);
|
||||
}
|
||||
return ['enabled' => true, 'items' => $items];
|
||||
return ['enabled' => true, 'agreement_visible' => $agreementVisible, 'items' => $items];
|
||||
}
|
||||
|
||||
public static function reports(array $params, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('reports', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
$rxId = (int) ($params['prescription_id'] ?? 0);
|
||||
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
||||
if (($rxId > 0) === ($diagnosisId > 0)) {
|
||||
@@ -100,22 +104,24 @@ final class PrescriptionAiLogic
|
||||
} while (count($chunk) === 200);
|
||||
$rows = array_slice($visible, ($page - 1) * $size, $size);
|
||||
$modelMap = self::models(array_column($rows, 'id'), false);
|
||||
return ['lists' => array_map(static fn ($b): array => self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $rows),
|
||||
return ['agreement_visible' => $agreementVisible,
|
||||
'lists' => array_map(static fn ($b): array => Visibility::projectBatch(self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $agreementVisible), $rows),
|
||||
'count' => count($visible), 'page_no' => $page, 'page_size' => $size];
|
||||
}
|
||||
|
||||
public static function detail(int $batchId, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('detail', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
$batch = self::loadBatch($batchId, $actor, $info);
|
||||
$models = self::models([$batchId], true);
|
||||
$models = self::models([$batchId], true, $agreementVisible);
|
||||
$detail = self::formatBatch($batch, $models[$batchId] ?? [], Access::prescription((int) $batch['prescription_id'], $actor, $info));
|
||||
// loadBatch has checked both current prescription and historical source permissions.
|
||||
// This cipher is saved at enqueue time, so it is available before models start and
|
||||
// remains the correct original when the user selects an older report revision.
|
||||
$detail['doctor_snapshot'] = empty($batch['prescription_cipher']) ? null
|
||||
: DoctorSnapshot::project((new Cipher())->decrypt($batch['prescription_cipher'], 'prescription'));
|
||||
return $detail;
|
||||
return Visibility::projectBatch($detail, $agreementVisible);
|
||||
}
|
||||
|
||||
public static function regenerate(int $rxId, string $reason, int $actor, array $info): array
|
||||
@@ -205,6 +211,9 @@ final class PrescriptionAiLogic
|
||||
|
||||
public static function statistics(array $params, int $actor, array $info): array
|
||||
{
|
||||
if (!Visibility::agreementVisible($actor)) {
|
||||
throw new DomainException('无权查看处方AI一致度统计');
|
||||
}
|
||||
self::requirePermission('statistics', $actor, $info);
|
||||
self::requirePermission('detail', $actor, $info);
|
||||
$from = self::date((string) ($params['date_from'] ?? date('Y-m-d', strtotime('-30 days'))));
|
||||
@@ -271,7 +280,7 @@ final class PrescriptionAiLogic
|
||||
'metric_label' => '药味与剂量一致度(不代表临床准确率)'];
|
||||
}
|
||||
|
||||
private static function models(array $batchIds, bool $full): array
|
||||
private static function models(array $batchIds, bool $full, bool $agreementVisible = true): array
|
||||
{
|
||||
if ($batchIds === []) {
|
||||
return [];
|
||||
@@ -301,7 +310,7 @@ final class PrescriptionAiLogic
|
||||
foreach ($results as $result) {
|
||||
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
|
||||
if ($full) {
|
||||
$body = self::localizeSourceMetadata($body);
|
||||
$body = Visibility::reportBody(self::localizeSourceMetadata($body), $agreementVisible);
|
||||
}
|
||||
unset($result['body_cipher']);
|
||||
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
|
||||
|
||||
@@ -5763,7 +5763,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单详情场景:更新主方/辅方服用次数与开立天数,以及订单服用天数
|
||||
* 业务订单详情场景:任意履约状态下更新主辅方服用参数,变更必须写入操作日志
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
@@ -5774,7 +5774,8 @@ class PrescriptionOrderLogic
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::execute(
|
||||
$prescriptionOrderId,
|
||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo)
|
||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo),
|
||||
false // 服用参数允许在提交药房后修正,继续保留订单/提交记录行锁与事务。
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
@@ -5799,21 +5800,13 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
if ((int) $order->fulfillment_status === 4) {
|
||||
self::setError('已取消的订单不可修改');
|
||||
|
||||
return false;
|
||||
}
|
||||
$rxId = (int) ($order->prescription_id ?? 0);
|
||||
if ($rxId <= 0) {
|
||||
self::setError('该订单未关联处方');
|
||||
|
||||
return false;
|
||||
}
|
||||
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
|
||||
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$rx) {
|
||||
self::setError('处方不存在');
|
||||
|
||||
@@ -5828,25 +5821,37 @@ class PrescriptionOrderLogic
|
||||
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
|
||||
$usageDays = (int) ($params['usage_days'] ?? 0);
|
||||
$medDays = (int) ($params['medication_days'] ?? 0);
|
||||
if ($timesPerDay < 1 || $timesPerDay > 6) {
|
||||
if (!self::isPrescriptionUsageInteger($params['times_per_day'] ?? null, 6)) {
|
||||
self::setError('主方每天次数须在 1~6 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($usageDays < 1 || $usageDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['usage_days'] ?? null, 999)) {
|
||||
self::setError('主方开立天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($medDays < 1 || $medDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['medication_days'] ?? null, 999)) {
|
||||
self::setError('订单服用天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$isWaterPill = (string) $rx->prescription_type === '浓缩水丸';
|
||||
$dosageBagCount = null;
|
||||
if ($isWaterPill && array_key_exists('dosage_bag_count', $params)) {
|
||||
if (!self::isPrescriptionUsageInteger($params['dosage_bag_count'], 5)) {
|
||||
self::setError('主方袋数须为 1~5 的整数');
|
||||
|
||||
return false;
|
||||
}
|
||||
$dosageBagCount = (int) $params['dosage_bag_count'];
|
||||
}
|
||||
|
||||
$hasAux = self::prescriptionHasAuxFormula($rx);
|
||||
$auxTimesPerDay = null;
|
||||
$auxUsageDays = null;
|
||||
$auxDosageBagCount = null;
|
||||
if ($hasAux) {
|
||||
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
|
||||
self::setError('含辅方处方须填写辅方服用参数');
|
||||
@@ -5855,20 +5860,29 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$auxTimesPerDay = (int) $params['aux_times_per_day'];
|
||||
$auxUsageDays = (int) $params['aux_usage_days'];
|
||||
if ($auxTimesPerDay < 1 || $auxTimesPerDay > 6) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_times_per_day'], 6)) {
|
||||
self::setError('辅方每天次数须在 1~6 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_usage_days'], 999)) {
|
||||
self::setError('辅方开立天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($isWaterPill && array_key_exists('aux_dosage_bag_count', $params)) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_dosage_bag_count'], 5)) {
|
||||
self::setError('辅方袋数须为 1~5 的整数');
|
||||
|
||||
return false;
|
||||
}
|
||||
$auxDosageBagCount = (int) $params['aux_dosage_bag_count'];
|
||||
}
|
||||
}
|
||||
|
||||
$oldTimes = (int) ($rx->times_per_day ?? 0);
|
||||
$oldUsageDays = (int) ($rx->usage_days ?? 0);
|
||||
$oldDosageBagCount = max(1, (int) ($rx->dosage_bag_count ?? 1));
|
||||
$oldMedDays = (int) ($order->medication_days ?? 0);
|
||||
$oldAuxUsage = $rx->aux_usage;
|
||||
if (is_string($oldAuxUsage)) {
|
||||
@@ -5880,16 +5894,48 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
|
||||
$oldAuxUsageDays = (int) ($oldAuxUsage['usage_days'] ?? 0);
|
||||
$oldAuxDosageBagCount = max(1, (int) ($oldAuxUsage['dosage_bag_count'] ?? 1));
|
||||
|
||||
$changes = [
|
||||
'主方每天次数' => [$oldTimes, $timesPerDay, '次'],
|
||||
'主方开立天数' => [$oldUsageDays, $usageDays, '天'],
|
||||
];
|
||||
if ($dosageBagCount !== null) {
|
||||
$changes['主方袋数'] = [$oldDosageBagCount, $dosageBagCount, '袋'];
|
||||
}
|
||||
if ($hasAux) {
|
||||
$changes['辅方每天次数'] = [$oldAuxTimes, $auxTimesPerDay, '次'];
|
||||
$changes['辅方开立天数'] = [$oldAuxUsageDays, $auxUsageDays, '天'];
|
||||
if ($auxDosageBagCount !== null) {
|
||||
$changes['辅方袋数'] = [$oldAuxDosageBagCount, $auxDosageBagCount, '袋'];
|
||||
}
|
||||
}
|
||||
$changes['订单服用天数'] = [$oldMedDays, $medDays, '天'];
|
||||
$parts = [];
|
||||
foreach ($changes as $label => [$oldValue, $newValue, $unit]) {
|
||||
if ($oldValue !== $newValue) {
|
||||
$parts[] = sprintf('%s %d%s → %d%s', $label, $oldValue, $unit, $newValue, $unit);
|
||||
}
|
||||
}
|
||||
if ($parts === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$rxUpdates = [
|
||||
'times_per_day' => $timesPerDay,
|
||||
'usage_days' => $usageDays,
|
||||
];
|
||||
if ($dosageBagCount !== null) {
|
||||
$rxUpdates['dosage_bag_count'] = $dosageBagCount;
|
||||
}
|
||||
if ($hasAux) {
|
||||
$auxUsage = $oldAuxUsage;
|
||||
$auxUsage['times_per_day'] = $auxTimesPerDay;
|
||||
$auxUsage['usage_days'] = $auxUsageDays;
|
||||
if ($auxDosageBagCount !== null) {
|
||||
$auxUsage['dosage_bag_count'] = $auxDosageBagCount;
|
||||
}
|
||||
$rxUpdates['aux_usage'] = $auxUsage;
|
||||
}
|
||||
$rx->save($rxUpdates);
|
||||
@@ -5897,31 +5943,8 @@ class PrescriptionOrderLogic
|
||||
$order->medication_days = $medDays;
|
||||
$order->save();
|
||||
|
||||
$parts = [
|
||||
sprintf(
|
||||
'主方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||
$oldTimes > 0 ? $oldTimes : 0,
|
||||
$oldUsageDays > 0 ? $oldUsageDays : 0,
|
||||
$timesPerDay,
|
||||
$usageDays
|
||||
),
|
||||
];
|
||||
if ($hasAux) {
|
||||
$parts[] = sprintf(
|
||||
'辅方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||
$oldAuxTimes > 0 ? $oldAuxTimes : 0,
|
||||
$oldAuxUsageDays > 0 ? $oldAuxUsageDays : 0,
|
||||
$auxTimesPerDay,
|
||||
$auxUsageDays
|
||||
);
|
||||
}
|
||||
$parts[] = sprintf(
|
||||
'订单设置 %d天 → %d天',
|
||||
$oldMedDays > 0 ? $oldMedDays : 0,
|
||||
$medDays
|
||||
);
|
||||
$summary = '服用参数:' . implode(';', $parts);
|
||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary);
|
||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary, true);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
@@ -5931,6 +5954,13 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
private static function isPrescriptionUsageInteger($value, int $max): bool
|
||||
{
|
||||
return (is_int($value) || is_string($value))
|
||||
&& preg_match('/^[1-9][0-9]*$/D', (string) $value) === 1
|
||||
&& (int) $value <= $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处方是否含辅方药材(与列表/详情 has_aux_formula 口径一致)
|
||||
*/
|
||||
|
||||
@@ -100,7 +100,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'uploadToPharmacy' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'],
|
||||
'updateAmount' => ['id', 'amount'],
|
||||
'setShipMode' => ['id', 'ship_mode'],
|
||||
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||
@@ -133,14 +133,28 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
->append('express_company', 'max:20');
|
||||
}
|
||||
|
||||
public function patchPrescriptionUsage(): PrescriptionOrderValidate
|
||||
public function scenePatchPrescriptionUsage(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'])
|
||||
$this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('times_per_day', 'require|integer|between:1,6')
|
||||
->append('usage_days', 'require|integer|between:1,999')
|
||||
->append('medication_days', 'require|integer|between:1,999')
|
||||
->append('aux_times_per_day', 'integer|between:1,6')
|
||||
->append('aux_usage_days', 'integer|between:1,999');
|
||||
|
||||
foreach (['dosage_bag_count' => '主方', 'aux_dosage_bag_count' => '辅方'] as $field => $label) {
|
||||
$this->append($field, [static function ($value, array $data) use ($field, $label) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (is_int($value) || is_string($value)) && preg_match('/^[1-5]$/D', (string) $value) === 1
|
||||
? true
|
||||
: $label . '袋数须为 1~5 的整数';
|
||||
}]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\prescriptionai;
|
||||
|
||||
use app\common\service\ConfigService;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** Independent access to agreement metrics; never grants access to a patient or report. */
|
||||
final class PrescriptionAiVisibility
|
||||
{
|
||||
public const CONFIG_TYPE = 'prescription_ai_visibility';
|
||||
public const CONFIG_NAME = 'agreement_policy';
|
||||
public const MAX_ROLES = 500;
|
||||
public const MAX_ADMINS = 5000;
|
||||
|
||||
private const COMPARISON_FIELDS = ['comparison', 'score', 'herb_score', 'comparison_status',
|
||||
'comparison_reason_code', 'comparison_type', 'baseline_eligible', 'baseline_exclusion_reasons',
|
||||
'baseline_exclusions_json', 'algorithm_version', 'dictionary_version',
|
||||
'raw', 'raw_body', 'raw_response', 'result', 'response', 'body', 'body_cipher', 'context_cipher', 'progress_cipher'];
|
||||
|
||||
// Scalar leaves and explicitly shaped lists prevent historical/debug body fields from escaping at any depth.
|
||||
private const REPORT_BODY_SCHEMA = [
|
||||
'report' => ['summary' => true, 'diagnosis' => true, 'treatment_advice' => true,
|
||||
'risk_assessment' => ['*' => ['label' => true, 'level' => true, 'evidence_references' => ['*' => true]]],
|
||||
'evidence_references' => ['*' => true], 'missing_information' => ['*' => true]],
|
||||
'candidate' => ['status' => true, 'reason' => true, 'prescription_name' => true, 'prescription_type' => true,
|
||||
'dose_basis' => true, 'usage_instruction' => true, 'times_per_day' => true, 'usage_days' => true,
|
||||
'rationale' => true, 'risk_warnings' => ['*' => true], 'evidence_references' => ['*' => true],
|
||||
'herbs' => ['*' => ['name' => true, 'dosage' => true, 'unit' => true, 'dose_basis' => true,
|
||||
'processing' => true, 'formula_type' => true, 'instructions' => true, 'evidence_references' => ['*' => true]]]],
|
||||
'coverage' => ['status' => true, 'complete' => true, 'source_complete' => true, 'source_ids' => ['*' => true],
|
||||
'token_budget_method' => true, 'clinical_interpretation_verified' => true,
|
||||
'missing' => ['*' => ['source_id' => true, 'code' => true, 'critical' => true]],
|
||||
'files' => ['*' => ['file_id' => true, 'type' => true, 'status' => true, 'transmitted' => true,
|
||||
'version_verified' => true, 'reason' => true]]],
|
||||
'usage' => ['total_calls' => true, 'calls' => ['*' => ['stage' => true, 'input_hash' => true, 'latency_ms' => true,
|
||||
'ok' => true, 'file_count' => true, 'input_token_upper_bound' => true, 'error_code' => true,
|
||||
'usage' => ['prompt_tokens' => true, 'completion_tokens' => true, 'total_tokens' => true]]]],
|
||||
'knowledge_source' => ['title' => true, 'url' => true, 'path' => true, 'commit' => true, 'sections' => ['*' => true]],
|
||||
'ancient_book_sources' => ['*' => ['title' => true, 'chapter' => true, 'excerpt' => true,
|
||||
'path' => true, 'line' => true, 'commit' => true, 'url' => true]],
|
||||
];
|
||||
|
||||
public static function capabilities(int $adminId): array
|
||||
{
|
||||
$actor = PrescriptionAiAccess::actor($adminId);
|
||||
if ($actor === null) {
|
||||
throw new DomainException('账号已停用或无权访问');
|
||||
}
|
||||
return ['agreement_visible' => self::visibleForActor($adminId),
|
||||
'can_manage_visibility' => PrescriptionAiAccess::allowed($adminId, $actor, 'manageVisibility')];
|
||||
}
|
||||
|
||||
public static function agreementVisible(int $adminId): bool
|
||||
{
|
||||
return PrescriptionAiAccess::actor($adminId) !== null && self::visibleForActor($adminId);
|
||||
}
|
||||
|
||||
public static function getConfig(int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
return self::loadPolicy();
|
||||
}
|
||||
|
||||
public static function setConfig(array $params, int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
$policy = self::normalizePolicy($params);
|
||||
foreach (['role_ids' => 'system_role', 'admin_ids' => 'admin'] as $field => $table) {
|
||||
if ($policy[$field] === []) {
|
||||
continue;
|
||||
}
|
||||
$query = Db::name($table)->whereIn('id', $policy[$field])->whereNull('delete_time');
|
||||
if ($table === 'admin') {
|
||||
$query->where('disable', 0);
|
||||
}
|
||||
if ((int) $query->count() !== count($policy[$field])) {
|
||||
throw new DomainException($table === 'admin' ? '授权用户不存在或已停用,请移除失效用户' : '授权角色不存在,请移除失效角色');
|
||||
}
|
||||
}
|
||||
// Store one object so readers never observe half of a role/user policy update.
|
||||
ConfigService::set(self::CONFIG_TYPE, self::CONFIG_NAME, $policy);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
public static function options(int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
// The endpoint is intentionally bounded. Never silently truncate an authorization selector.
|
||||
$roles = Db::name('system_role')->whereNull('delete_time')->field('id,name')->order('id')
|
||||
->limit(self::MAX_ROLES + 1)->select()->toArray();
|
||||
$admins = Db::name('admin')->whereNull('delete_time')->where('disable', 0)->field('id,name,account')->order('id')
|
||||
->limit(self::MAX_ADMINS + 1)->select()->toArray();
|
||||
if (count($roles) > self::MAX_ROLES || count($admins) > self::MAX_ADMINS) {
|
||||
throw new DomainException('授权选项超过上限(500个角色、5000个用户),请联系管理员');
|
||||
}
|
||||
return ['roles' => array_map(static fn (array $r): array => ['id' => (int) $r['id'], 'name' => (string) $r['name']], $roles),
|
||||
'admins' => array_map(static fn (array $a): array => ['id' => (int) $a['id'], 'name' => (string) $a['name'],
|
||||
'account' => (string) $a['account']], $admins)];
|
||||
}
|
||||
|
||||
public static function normalizePolicy(array $params): array
|
||||
{
|
||||
if (array_diff(array_keys($params), ['role_ids', 'admin_ids']) !== []
|
||||
|| !array_key_exists('role_ids', $params) || !array_key_exists('admin_ids', $params)) {
|
||||
throw new DomainException('授权配置须包含且仅包含角色和用户列表');
|
||||
}
|
||||
return ['role_ids' => self::ids($params['role_ids'], self::MAX_ROLES),
|
||||
'admin_ids' => self::ids($params['admin_ids'], self::MAX_ADMINS)];
|
||||
}
|
||||
|
||||
/** Build the response from known saved-body fields, never merge an arbitrary decrypted object. */
|
||||
public static function reportBody(array $body, bool $agreementVisible): array
|
||||
{
|
||||
$projected = self::projectValue($body, self::REPORT_BODY_SCHEMA);
|
||||
if ($agreementVisible && array_key_exists('comparison', $body)) {
|
||||
$projected['comparison'] = $body['comparison'];
|
||||
}
|
||||
return $projected;
|
||||
}
|
||||
|
||||
public static function projectBatch(array $batch, bool $agreementVisible): array
|
||||
{
|
||||
if (!$agreementVisible) {
|
||||
$batch = self::withoutComparison($batch);
|
||||
foreach ($batch['models'] ?? [] as $key => $model) {
|
||||
// A failed task's reason is a generation error; a successful result's reason is a comparison.
|
||||
if (($model['error_code'] ?? '') === '') {
|
||||
unset($batch['models'][$key]['reason']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$batch['agreement_visible'] = $agreementVisible;
|
||||
return $batch;
|
||||
}
|
||||
|
||||
private static function withoutComparison(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $item) {
|
||||
if (in_array($key, self::COMPARISON_FIELDS, true)) {
|
||||
unset($value[$key]);
|
||||
} elseif (is_array($item)) {
|
||||
$value[$key] = self::withoutComparison($item);
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function projectValue($value, $schema)
|
||||
{
|
||||
if ($schema === true) {
|
||||
return is_scalar($value) || $value === null ? $value : null;
|
||||
}
|
||||
if (!is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
if (array_key_exists('*', $schema)) {
|
||||
return array_map(static fn ($item) => self::projectValue($item, $schema['*']), array_values($value));
|
||||
}
|
||||
$result = [];
|
||||
foreach ($schema as $key => $shape) {
|
||||
if (array_key_exists($key, $value)) {
|
||||
$result[$key] = self::projectValue($value[$key], $shape);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function requireManage(int $adminId): void
|
||||
{
|
||||
$actor = PrescriptionAiAccess::actor($adminId);
|
||||
if ($actor === null || !PrescriptionAiAccess::allowed($adminId, $actor, 'manageVisibility')) {
|
||||
throw new DomainException('无权管理处方AI一致度授权');
|
||||
}
|
||||
}
|
||||
|
||||
private static function visibleForActor(int $adminId): bool
|
||||
{
|
||||
$policy = self::loadPolicy();
|
||||
if (in_array($adminId, $policy['admin_ids'], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($policy['role_ids'] === []) {
|
||||
return false;
|
||||
}
|
||||
// Read current membership and active roles, never the login token's role snapshot.
|
||||
return Db::name('admin_role')->alias('ar')->join('system_role r', 'r.id=ar.role_id')
|
||||
->where('ar.admin_id', $adminId)->whereIn('r.id', $policy['role_ids'])->whereNull('r.delete_time')->count() > 0;
|
||||
}
|
||||
|
||||
private static function loadPolicy(): array
|
||||
{
|
||||
$empty = ['role_ids' => [], 'admin_ids' => []];
|
||||
$stored = ConfigService::get(self::CONFIG_TYPE, self::CONFIG_NAME, $empty);
|
||||
if (!is_array($stored)) {
|
||||
return $empty;
|
||||
}
|
||||
try {
|
||||
return self::normalizePolicy($stored);
|
||||
} catch (DomainException $e) {
|
||||
return $empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static function ids($raw, int $max): array
|
||||
{
|
||||
if (!is_array($raw) || !array_is_list($raw) || count($raw) > $max) {
|
||||
throw new DomainException('授权列表格式或数量无效');
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($raw as $id) {
|
||||
if ((!is_int($id) && !is_string($id)) || !preg_match('/^[1-9]\d{0,17}$/D', (string) $id)) {
|
||||
throw new DomainException('授权标识须为有效正整数');
|
||||
}
|
||||
$ids[] = (int) $id;
|
||||
}
|
||||
$ids = array_values(array_unique($ids));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Agreement visibility is a separate allowlist. This migration grants no role access.
|
||||
START TRANSACTION;
|
||||
|
||||
SET @rx_ai_visibility_setting := (
|
||||
SELECT id FROM zyt_system_menu
|
||||
WHERE type='M' AND (paths IN ('setting','/setting') OR name='系统设置')
|
||||
ORDER BY CASE WHEN paths IN ('setting','/setting') THEN 0 ELSE 1 END,id
|
||||
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 COALESCE(@rx_ai_visibility_setting,0),'C','处方AI一致度授权','el-icon-Lock',91,
|
||||
'tcm.prescriptionAi/manageVisibility','prescription_ai_visibility','setting/prescription_ai_visibility/index',
|
||||
'','',0,1,0,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM zyt_system_menu WHERE perms='tcm.prescriptionAi/manageVisibility'
|
||||
);
|
||||
|
||||
-- Seed one complete empty policy. Runtime ConfigService writes update this same row.
|
||||
INSERT INTO zyt_config (type,name,value,create_time,update_time)
|
||||
SELECT 'prescription_ai_visibility','agreement_policy','{"role_ids":[],"admin_ids":[]}',UNIX_TIMESTAMP(),UNIX_TIMESTAMP()
|
||||
FROM DUAL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM zyt_config WHERE type='prescription_ai_visibility' AND name='agreement_policy'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -11,6 +11,9 @@ use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
|
||||
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
|
||||
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
|
||||
use app\common\service\prescriptionai\PrescriptionAiRequest as SaveRequest;
|
||||
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
|
||||
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
|
||||
use app\common\service\ConfigService;
|
||||
use app\adminapi\logic\tcm\PrescriptionAiLogic as Api;
|
||||
use think\Container;
|
||||
use think\facade\Db;
|
||||
@@ -56,11 +59,15 @@ $root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'role_id' => [], 'dept_id' =>
|
||||
try {
|
||||
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY AUTO_INCREMENT,pid INT,type VARCHAR(5),name VARCHAR(100),icon VARCHAR(50),sort INT,perms VARCHAR(100),paths VARCHAR(100),component VARCHAR(100),selected VARCHAR(100),params VARCHAR(100),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,UNIQUE KEY(role_id,menu_id))');
|
||||
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL)');
|
||||
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL,account VARCHAR(50) DEFAULT "fixture")');
|
||||
$pdo->exec('CREATE TABLE zyt_system_role (id INT PRIMARY KEY,name VARCHAR(50),data_scope INT DEFAULT 1,delete_time INT NULL)');
|
||||
$pdo->exec('CREATE TABLE zyt_config (id INT PRIMARY KEY AUTO_INCREMENT,type VARCHAR(30),name VARCHAR(60),value TEXT,create_time INT,update_time INT)');
|
||||
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT,role_id INT)');
|
||||
$pdo->exec('CREATE TABLE zyt_admin_dept (admin_id INT,dept_id INT)');
|
||||
$pdo->exec('CREATE TABLE zyt_admin_jobs (admin_id INT,jobs_id INT)');
|
||||
$pdo->exec("INSERT INTO zyt_admin VALUES(1,'Test',1,0,NULL)");
|
||||
$pdo->exec("INSERT INTO zyt_admin(id,name,root,disable,delete_time) VALUES(1,'Test',1,0,NULL),(2,'Role user',0,0,NULL),(3,'Direct user',0,0,NULL),(4,'Disabled user',0,1,NULL),(5,'Deleted user',0,0,1),(6,'Manager',0,0,NULL)");
|
||||
$pdo->exec("INSERT INTO zyt_system_role VALUES(7,'Selected role',1,NULL),(8,'Other role',1,NULL),(9,'Deleted role',1,1),(10,'Policy manager',1,NULL)");
|
||||
$pdo->exec('INSERT INTO zyt_admin_role VALUES(2,7),(2,8),(6,10)');
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY,patient_id INT,assistant_id INT DEFAULT 1,delete_time INT NULL)');
|
||||
$pdo->exec('INSERT INTO zyt_tcm_diagnosis(id,patient_id) VALUES(1,100),(2,200)');
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_prescription (id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT DEFAULT 1,patient_id INT DEFAULT 100,creator_id INT DEFAULT 1,is_system_auto INT DEFAULT 0,void_status INT DEFAULT 0,delete_time INT NULL,herbs TEXT,prescription_type VARCHAR(30) DEFAULT "饮片",update_time INT DEFAULT 0) ENGINE=InnoDB');
|
||||
@@ -86,6 +93,87 @@ try {
|
||||
if (trim($statement) !== '') { $pdo->exec($statement); }
|
||||
}
|
||||
$expect((int) Db::name('system_menu')->count() === 7, 'idempotent permission migration');
|
||||
$visibilityMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql');
|
||||
for ($repeat = 0; $repeat < 2; $repeat++) {
|
||||
foreach (explode(';', preg_replace('/^--.*$/m', '', $visibilityMigration)) as $statement) {
|
||||
if (trim($statement) !== '') { $pdo->exec($statement); }
|
||||
}
|
||||
}
|
||||
$expect((int) Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->count() === 1
|
||||
&& (int) Db::name('config')->count() === 1, 'visibility migration is idempotent and seeds one empty policy');
|
||||
$expect((int) Db::name('system_role_menu')->count() === 0, 'migration grants no nonroot role management permission');
|
||||
$expect(Visibility::capabilities(1) === ['agreement_visible' => false, 'can_manage_visibility' => true],
|
||||
'root manages policy but does not bypass empty view allowlist');
|
||||
$expect(Visibility::getConfig(1) === ['role_ids' => [], 'admin_ids' => []], 'default policy is closed');
|
||||
$denied = static function (callable $call, string $message) use ($expect): void {
|
||||
try { $call(); $expect(false, $message); }
|
||||
catch (DomainException $e) { $expect(true, $message); }
|
||||
};
|
||||
$http = static function (string $action, int $adminId = 1, string $method = 'GET', array $params = []): array {
|
||||
// Run the real final controller against a local request without bootstrapping an HTTP app.
|
||||
$reflection = new ReflectionClass(\app\adminapi\controller\tcm\PrescriptionAiController::class);
|
||||
$controller = $reflection->newInstanceWithoutConstructor();
|
||||
$request = (new think\Request())->setMethod($method);
|
||||
$request = $method === 'POST' ? $request->withPost($params) : $request->withGet($params);
|
||||
foreach (['request' => $request, 'adminId' => $adminId,
|
||||
'adminInfo' => ['root' => 1, 'role_id' => [7], 'permissions' => ['*']]] as $property => $value) {
|
||||
$reflection->getProperty($property)->setValue($controller, $value);
|
||||
}
|
||||
return $controller->$action()->getData();
|
||||
};
|
||||
$expect($http('capabilities', 2)['data'] === ['agreement_visible' => false, 'can_manage_visibility' => false],
|
||||
'capabilities is a login-only self-check, never trusts stale root/star token information');
|
||||
$expect($http('capabilities', 4)['code'] === 0, 'disabled account cannot call capabilities with stale root token');
|
||||
foreach (['getVisibilityConfig', 'visibilityOptions'] as $action) {
|
||||
$expect($http($action, 2)['code'] === 0 && $http($action, 1, 'POST')['code'] === 0,
|
||||
'config read/options enforce current manager and GET method');
|
||||
}
|
||||
$expect($http('setVisibilityConfig', 2, 'POST', ['role_ids' => [], 'admin_ids' => [2]])['code'] === 0
|
||||
&& $http('setVisibilityConfig', 1, 'GET')['code'] === 0
|
||||
&& $http('setVisibilityConfig', 1, 'POST', ['role_ids' => [], 'admin_ids' => [], 'root' => true])['code'] === 0,
|
||||
'config write rejects manager escalation, wrong method and unknown parameters');
|
||||
foreach ([static fn () => Visibility::getConfig(2), static fn () => Visibility::options(2),
|
||||
static fn () => Visibility::setConfig(['role_ids' => [], 'admin_ids' => [2]], 2)] as $operation) {
|
||||
$denied($operation, 'ordinary account cannot manage allowlist despite unregistered config routes');
|
||||
}
|
||||
Db::name('system_role_menu')->insert(['role_id' => 10,
|
||||
'menu_id' => Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->value('id')]);
|
||||
$expect(Visibility::capabilities(6) === ['agreement_visible' => false, 'can_manage_visibility' => true],
|
||||
'dedicated permission enables management without granting agreement view');
|
||||
$selected = Visibility::setConfig(['role_ids' => ['7', 7], 'admin_ids' => [3]], 6);
|
||||
$expect($selected === ['role_ids' => [7], 'admin_ids' => [3]], 'authorized manager saves canonical policy');
|
||||
$expect(Visibility::agreementVisible(2) && Visibility::agreementVisible(3) && !Visibility::agreementVisible(1),
|
||||
'role union and direct user are alternatives, root has no implicit view');
|
||||
$denied(static fn () => Api::statuses([], 2, Access::actor(2)), 'allowlist does not replace existing action RBAC');
|
||||
Db::name('system_role_menu')->insert(['role_id' => 8,
|
||||
'menu_id' => Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/statuses')->value('id')]);
|
||||
$expect(Api::statuses([], 2, Access::actor(2))['agreement_visible'] === true,
|
||||
'selected role plus independent action permission can view metric capability');
|
||||
Db::name('admin_role')->where('admin_id', 2)->where('role_id', 7)->delete();
|
||||
$expect(!Visibility::agreementVisible(2), 'role membership revocation applies immediately');
|
||||
Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 7]);
|
||||
Db::name('system_role')->where('id', 7)->update(['delete_time' => 1]);
|
||||
$expect(!Visibility::agreementVisible(2), 'deleted selected role never authorizes stale membership');
|
||||
Db::name('system_role')->where('id', 7)->update(['delete_time' => null]);
|
||||
$denied(static fn () => Visibility::setConfig(['role_ids' => [9], 'admin_ids' => []], 1), 'deleted role cannot be newly selected');
|
||||
$denied(static fn () => Visibility::setConfig(['role_ids' => [], 'admin_ids' => [4]], 1), 'disabled user cannot be newly selected');
|
||||
$options = Visibility::options(6);
|
||||
$expect(array_column($options['roles'], 'id') === [7, 8, 10]
|
||||
&& array_column($options['admins'], 'id') === [1, 2, 3, 6]
|
||||
&& array_keys($options['admins'][0]) === ['id', 'name', 'account'], 'options contain active records and minimal account fields only');
|
||||
Db::name('system_role')->insertAll(array_map(static fn (int $id): array =>
|
||||
['id' => $id, 'name' => 'bounded selector fixture'], range(100, 599)));
|
||||
$denied(static fn () => Visibility::options(1), 'oversized selector fails explicitly instead of silently truncating');
|
||||
Db::name('system_role')->where('id', '>=', 100)->delete();
|
||||
Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->update(['is_disable' => 1]);
|
||||
$denied(static fn () => Visibility::getConfig(6), 'disabled management permission is revoked immediately');
|
||||
Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->update(['is_disable' => 0]);
|
||||
Db::name('config')->where('type', Visibility::CONFIG_TYPE)->update(['value' => '{"role_ids":"*","admin_ids":[1]}']);
|
||||
$expect(!Visibility::agreementVisible(1) && !Visibility::agreementVisible(3), 'malformed policy fails closed as a whole');
|
||||
ConfigService::set(Visibility::CONFIG_TYPE, Visibility::CONFIG_NAME, ['role_ids' => [], 'admin_ids' => [1, 4, 5]]);
|
||||
$expect(Visibility::agreementVisible(1) && !Visibility::agreementVisible(4) && !Visibility::agreementVisible(5),
|
||||
'stale direct grants cannot enable disabled or deleted accounts');
|
||||
Visibility::setConfig(['role_ids' => [], 'admin_ids' => [1]], 1);
|
||||
if (!$legacyProgressSchema) {
|
||||
$progressMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_10_prescription_ai_progress.sql');
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
@@ -225,6 +313,45 @@ try {
|
||||
'report body cannot override trusted task progress');
|
||||
Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root);
|
||||
$expect(Api::detail($batchId, 1, $root)['models']['qwen']['review']['status'] === 'not_adopted', 'review independent of prescription');
|
||||
$resultBody['comparison']['reason'] = 'hidden-metric-sentinel';
|
||||
$resultBody['comparison']['normalization'] = ['matched_contribution_sum' => 'hidden-metric-sentinel'];
|
||||
foreach (['score', 'herb_score', 'raw', 'result', 'response', 'body'] as $rawKey) {
|
||||
$resultBody[$rawKey] = ['unknown' => 'hidden-metric-sentinel'];
|
||||
}
|
||||
$resultBody['report']['unknown_debug'] = ['normalization' => 'hidden-metric-sentinel'];
|
||||
$resultBody['candidate']['unknown_debug'] = 'hidden-metric-sentinel';
|
||||
Db::name('prescription_ai_result')->where('id', $resultRow['id'])->update([
|
||||
'body_cipher' => (new Cipher())->encrypt($resultBody, 'result:' . $batchId . ':qwen'),
|
||||
]);
|
||||
Visibility::setConfig(['role_ids' => [], 'admin_ids' => []], 1);
|
||||
$hiddenStatus = Api::statuses([$rx['id']], 1, $root);
|
||||
$hiddenReports = Api::reports(['prescription_id' => $rx['id']], 1, $root);
|
||||
$hiddenDetail = Api::detail($batchId, 1, $root);
|
||||
$expect($hiddenStatus['agreement_visible'] === false && $hiddenStatus['items'][0]['agreement_visible'] === false
|
||||
&& $hiddenReports['agreement_visible'] === false && $hiddenReports['lists'][0]['agreement_visible'] === false
|
||||
&& $hiddenDetail['agreement_visible'] === false, 'all list/history/detail levels carry current agreement capability');
|
||||
foreach ([$hiddenStatus['items'][0], $hiddenReports['lists'][0], $hiddenDetail] as $hiddenBatch) {
|
||||
$expect(!isset($hiddenBatch['comparison_type']) && !isset($hiddenBatch['baseline_eligible'])
|
||||
&& !isset($hiddenBatch['baseline_exclusion_reasons'])
|
||||
&& !isset($hiddenBatch['models']['qwen']['score']) && !isset($hiddenBatch['models']['qwen']['herb_score'])
|
||||
&& !isset($hiddenBatch['models']['qwen']['comparison']) && !isset($hiddenBatch['models']['qwen']['comparison_status'])
|
||||
&& !isset($hiddenBatch['models']['qwen']['comparison_reason_code']) && !isset($hiddenBatch['models']['qwen']['reason'])
|
||||
&& !str_contains(json_encode($hiddenBatch), 'hidden-metric-sentinel'), 'shared API projection removes metrics and raw-body paths');
|
||||
}
|
||||
$expect($hiddenDetail['models']['qwen']['report']['summary'] === 'synthetic report'
|
||||
&& $hiddenDetail['models']['qwen']['candidate']['status'] === 'available_for_review'
|
||||
&& $hiddenDetail['doctor_snapshot'] === $snapshot && $hiddenDetail['models']['qwen']['review']['status'] === 'not_adopted'
|
||||
&& $hiddenDetail['models']['openai']['reason'] !== '', 'hidden metric preserves report, candidate, original, review and failure explanation');
|
||||
$denied(static fn () => Api::statistics([], 1, $root), 'root without selected grant cannot read agreement statistics');
|
||||
Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root);
|
||||
$expect(Api::retry($batchId, 'openai', 1, $root)['status'] === 'retry_wait',
|
||||
'agreement restriction does not remove independent report review/retry permission');
|
||||
Visibility::setConfig(['role_ids' => [], 'admin_ids' => [1]], 1);
|
||||
$restored = Api::detail($batchId, 1, $root);
|
||||
$expect($restored['agreement_visible'] && $restored['models']['qwen']['score'] === 80.0
|
||||
&& $restored['models']['qwen']['comparison']['score'] === 80
|
||||
&& !isset($restored['models']['qwen']['raw']) && !isset($restored['models']['qwen']['result']),
|
||||
'selected root gets genuine persisted metric, never arbitrary decrypted-body aliases');
|
||||
Api::retry($batchId, 'openai', 1, $root);
|
||||
$o2 = Store::claimTask('openai');
|
||||
$expect((int) $o2['total_attempts'] === 2 && (int) $o2['attempts'] === 1, 'manual retry preserves lifetime attempts');
|
||||
@@ -363,7 +490,7 @@ try {
|
||||
$before = (int) Db::name('prescription_ai_batch')->count();
|
||||
$config->set(['enabled' => false], 'prescription_analysis');
|
||||
$expect($save($fixture()) === null && (int) Db::name('prescription_ai_batch')->count() === $before, 'feature disabled makes no outbox writes');
|
||||
$expect(Api::statuses([1], 1, $root) === ['enabled' => false, 'items' => []], 'disabled list has graceful compatibility');
|
||||
$expect(Api::statuses([1], 1, $root) === ['enabled' => false, 'agreement_visible' => true, 'items' => []], 'disabled list has graceful compatibility');
|
||||
echo "Prescription AI queue: {$checks} checks passed\n";
|
||||
} finally {
|
||||
$pdo->exec("DROP DATABASE `{$database}`");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Pure policy normalization and presentation checks: no App initialize, .env, database or network.
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
|
||||
|
||||
$checks = 0;
|
||||
$expect = static function (bool $condition, string $message) use (&$checks): void {
|
||||
if (!$condition) { throw new RuntimeException($message); }
|
||||
$checks++;
|
||||
};
|
||||
$expect(Visibility::normalizePolicy(['role_ids' => ['7', 2, 7], 'admin_ids' => ['9', 3]])
|
||||
=== ['role_ids' => [2, 7], 'admin_ids' => [3, 9]], 'IDs normalize, deduplicate and sort');
|
||||
$expect(Visibility::normalizePolicy(['role_ids' => [], 'admin_ids' => []])
|
||||
=== ['role_ids' => [], 'admin_ids' => []], 'empty policy stays empty');
|
||||
foreach ([[], ['role_ids' => []], ['role_ids' => [], 'admin_ids' => [], 'root' => true],
|
||||
['role_ids' => '*', 'admin_ids' => []], ['role_ids' => [true], 'admin_ids' => []],
|
||||
['role_ids' => [1.0], 'admin_ids' => []], ['role_ids' => [-1], 'admin_ids' => []],
|
||||
['role_ids' => ['01'], 'admin_ids' => []], ['role_ids' => ['1,2'], 'admin_ids' => []],
|
||||
['role_ids' => ['id' => 1], 'admin_ids' => []], ['role_ids' => [PHP_INT_MAX], 'admin_ids' => []],
|
||||
['role_ids' => array_fill(0, Visibility::MAX_ROLES + 1, 1), 'admin_ids' => []]] as $invalid) {
|
||||
try { Visibility::normalizePolicy($invalid); $expect(false, 'invalid policy must fail'); }
|
||||
catch (DomainException $e) { $checks++; }
|
||||
}
|
||||
|
||||
$sentinel = 'private-agreement-sentinel';
|
||||
$body = ['report' => ['summary' => 'clinical report', 'comparison' => ['reason' => $sentinel], 'raw' => [$sentinel],
|
||||
'unknown_debug' => ['normalization' => $sentinel]],
|
||||
'candidate' => ['status' => 'available_for_review', 'reason' => 'clinical reasoning', 'herbs' => [['name' => '测试药材']]],
|
||||
'comparison' => ['score' => 88, 'normalization' => ['matched_contribution_sum' => 2]],
|
||||
'coverage' => ['status' => 'complete'], 'usage' => ['total_calls' => 2],
|
||||
'knowledge_source' => ['title' => 'clinical reference'], 'ancient_book_sources' => [['title' => 'book']],
|
||||
'score' => $sentinel, 'result' => [$sentinel], 'raw' => [$sentinel], 'body' => [$sentinel],
|
||||
'progress' => ['stage' => 'poisoned']];
|
||||
$public = Visibility::reportBody($body, false);
|
||||
$expect(!str_contains(json_encode($public), $sentinel) && !isset($public['comparison']), 'comparison and unknown raw body paths cannot escape');
|
||||
$expect($public['report']['summary'] === 'clinical report' && $public['candidate']['reason'] === 'clinical reasoning'
|
||||
&& $public['coverage']['status'] === 'complete', 'report, candidate reasoning and coverage survive');
|
||||
$private = Visibility::reportBody($body, true);
|
||||
$expect($private['comparison']['score'] === 88 && !isset($private['result']) && !isset($private['progress']),
|
||||
'authorized comparison survives but arbitrary body cannot override response fields');
|
||||
$expect(!isset($private['report']['unknown_debug']) && !isset($private['report']['raw']),
|
||||
'known clinical report fields are projected at every depth for all viewers');
|
||||
$batch = ['comparison_type' => $sentinel, 'baseline_eligible' => true, 'baseline_exclusion_reasons' => [$sentinel],
|
||||
'models' => ['qwen' => ['status' => 'success', 'score' => 88, 'herb_score' => 90,
|
||||
'reason' => $sentinel, 'comparison_status' => 'comparable', 'comparison_reason_code' => $sentinel,
|
||||
'algorithm_version' => $sentinel, 'dictionary_version' => $sentinel, 'error_code' => '',
|
||||
'comparison' => ['rows' => [$sentinel]], 'progress' => ['stage' => 'completed']] + $public,
|
||||
'openai' => ['status' => 'failed', 'error_code' => 'UPSTREAM_TIMEOUT', 'reason' => '模型请求超时']]];
|
||||
$hidden = Visibility::projectBatch($batch, false);
|
||||
$expect($hidden['agreement_visible'] === false && !str_contains(json_encode($hidden), $sentinel), 'whole batch removes all metric aliases and derived fields');
|
||||
$expect($hidden['models']['openai']['reason'] === '模型请求超时'
|
||||
&& $hidden['models']['qwen']['progress']['stage'] === 'completed', 'failure explanation and trusted progress remain');
|
||||
$expect(Visibility::projectBatch($batch, true)['models']['qwen']['score'] === 88, 'authorized stored metric is unchanged');
|
||||
$expect(Visibility::projectBatch(['models' => []], false) === ['models' => [], 'agreement_visible' => false], 'empty batch advertises capability');
|
||||
|
||||
echo "Prescription AI visibility: {$checks} offline checks passed\n";
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Main/auxiliary usage edits in every order state, using real validation, ORM and transactions.
|
||||
* Set ZYT_AUX_BAG_TEST_MYSQL_PORT to an explicitly selected isolated root/empty-password instance.
|
||||
* Never initializes the application or loads its business database configuration.
|
||||
*/
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use think\Container;
|
||||
use think\DbManager;
|
||||
use think\facade\Db;
|
||||
|
||||
$port = (int) getenv('ZYT_AUX_BAG_TEST_MYSQL_PORT');
|
||||
if ($port <= 0 || $port === 3306) {
|
||||
fwrite(STDERR, "Set ZYT_AUX_BAG_TEST_MYSQL_PORT to an isolated local MySQL instance, outside port 3306.\n");
|
||||
exit(1);
|
||||
}
|
||||
$database = 'aux_bag_test_' . 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::ATTR_TIMEOUT => 2,
|
||||
]);
|
||||
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
|
||||
$pdo->exec("USE `{$database}`");
|
||||
$testApp = new think\App(); // Do not initialize application configuration or services.
|
||||
$manager = new DbManager();
|
||||
$manager->setConfig([
|
||||
'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
|
||||
'connections' => ['mysql' => [
|
||||
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
|
||||
'database' => $database, 'username' => 'root', 'password' => '',
|
||||
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true,
|
||||
]],
|
||||
]);
|
||||
$config = new think\Config();
|
||||
$config->set(['data_scope' => ['enabled' => false]], 'project');
|
||||
Container::getInstance()->instance('think\DbManager', $manager);
|
||||
Container::getInstance()->instance('config', $config);
|
||||
$testLang = new think\Lang($testApp);
|
||||
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
|
||||
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $message) use (&$checks): void {
|
||||
if (!$ok) {
|
||||
throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError());
|
||||
}
|
||||
++$checks;
|
||||
};
|
||||
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员'];
|
||||
$params = static fn (int $id): array => [
|
||||
'id' => $id, 'times_per_day' => 3, 'usage_days' => 9, 'medication_days' => 8,
|
||||
'aux_times_per_day' => 2, 'aux_usage_days' => 6,
|
||||
];
|
||||
$unchangedParams = static fn (int $id): array => [
|
||||
'id' => $id, 'times_per_day' => 3, 'usage_days' => 7, 'medication_days' => 7,
|
||||
'aux_times_per_day' => 3, 'aux_usage_days' => 7,
|
||||
];
|
||||
$validate = static fn (array $data): bool => (new PrescriptionOrderValidate())
|
||||
->scene('patchPrescriptionUsage')->check($data);
|
||||
|
||||
try {
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_prescription (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, creator_id INT DEFAULT 1, assistant_id INT DEFAULT 0,
|
||||
is_shared INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "", gender INT DEFAULT 0,
|
||||
prescription_type VARCHAR(30) DEFAULT "浓缩水丸", herbs JSON, aux_usage JSON,
|
||||
dosage_amount DECIMAL(10,2) DEFAULT 5, dosage_bag_count INT DEFAULT 2,
|
||||
times_per_day INT DEFAULT 3, usage_days INT DEFAULT 7,
|
||||
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, prescription_id INT DEFAULT 0, creator_id INT DEFAULT 1,
|
||||
fulfillment_status INT DEFAULT 2, medication_days INT DEFAULT 7,
|
||||
gancao_reciperl_order_no VARCHAR(100) DEFAULT "", ej_pharmacy_order_no VARCHAR(100) DEFAULT "",
|
||||
gancao_submit_time INT DEFAULT 0, ej_pharmacy_submit_time INT DEFAULT 0,
|
||||
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order_log (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, admin_id INT,
|
||||
admin_name VARCHAR(64), action VARCHAR(32), summary VARCHAR(500), create_time INT
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_pharmacy_submission_claim (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, source_revision INT, status VARCHAR(50)
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)');
|
||||
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)');
|
||||
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(100), is_disable INT DEFAULT 0)');
|
||||
|
||||
$auxSeed = [
|
||||
'dosage_amount' => 5, 'dosage_bag_count' => 1, 'times_per_day' => 3, 'usage_days' => 7,
|
||||
'prescription_name' => '测试辅方', 'need_decoction' => 0, 'bags_per_dose' => 1,
|
||||
'extra_metadata' => ['source' => 'fixture'],
|
||||
];
|
||||
$fixture = static function (array $rx = [], array $order = []) use ($auxSeed): int {
|
||||
$rxId = (int) Db::name('tcm_prescription')->insertGetId(array_replace([
|
||||
'herbs' => json_encode([['formula_type' => '主方'], ['formula_type' => '辅方']], JSON_UNESCAPED_UNICODE),
|
||||
'aux_usage' => json_encode($auxSeed, JSON_UNESCAPED_UNICODE),
|
||||
], $rx));
|
||||
|
||||
return (int) Db::name('tcm_prescription_order')->insertGetId(array_replace(['prescription_id' => $rxId], $order));
|
||||
};
|
||||
$orderRow = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find();
|
||||
$rxRow = static fn (int $id): array => Db::name('tcm_prescription')
|
||||
->where('id', $orderRow($id)['prescription_id'])->find();
|
||||
$rxModel = static fn (int $id): Prescription => Prescription::find((int) $orderRow($id)['prescription_id']);
|
||||
$logs = static fn (int $id): array => Db::name('tcm_prescription_order_log')
|
||||
->where('prescription_order_id', $id)->order('id')->select()->toArray();
|
||||
$snapshot = static fn (int $id): array => [$orderRow($id), $rxRow($id), $logs($id)];
|
||||
|
||||
// Exercise the same scene name used by the controller, including backward-compatible omission.
|
||||
$expect($validate($params(1)), 'Legacy requests without a bag count must remain valid');
|
||||
$invalidCounts = [0, 6, -1, 1.5, 2.0, '1.5', '2.0', 'abc', '2bags', ' 2 ', true, false, null, '', [], [2]];
|
||||
foreach (['dosage_bag_count', 'aux_dosage_bag_count'] as $field) {
|
||||
foreach ([1, 5, '2'] as $count) {
|
||||
$expect($validate($params(1) + [$field => $count]), 'Valid integer bag count must pass the actual scene');
|
||||
}
|
||||
foreach ($invalidCounts as $count) {
|
||||
$expect(!$validate($params(1) + [$field => $count]), 'Supplied invalid bag count must fail the actual scene');
|
||||
}
|
||||
}
|
||||
foreach ([['id' => 0], ['times_per_day' => 'invalid'], ['usage_days' => 0], ['medication_days' => 1000],
|
||||
['aux_times_per_day' => 7], ['aux_usage_days' => 0]] as $invalidField) {
|
||||
$expect(!$validate(array_replace($params(1), $invalidField)), 'Existing usage rules must execute through the corrected scene');
|
||||
}
|
||||
|
||||
// Persist through the public transaction wrapper, then read the real JSON column and model back.
|
||||
foreach ([1, 5, '3'] as $count) {
|
||||
$id = $fixture();
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => $count], 1, $admin),
|
||||
'Valid auxiliary bag count must save');
|
||||
$expectedAux = array_replace($auxSeed, ['times_per_day' => 2, 'usage_days' => 6, 'dosage_bag_count' => (int) $count]);
|
||||
$savedAux = json_decode($rxRow($id)['aux_usage'], true);
|
||||
$modelAux = $rxModel($id)->aux_usage;
|
||||
$expect($savedAux == $expectedAux && $modelAux == $expectedAux
|
||||
&& $savedAux['dosage_bag_count'] === (int) $count && $modelAux['dosage_bag_count'] === (int) $count,
|
||||
'Saved bag count must round-trip as an integer while preserving all other auxiliary JSON keys');
|
||||
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->dosage_amount === 5.0,
|
||||
'Auxiliary edits must preserve main dosage and bag count');
|
||||
$expect((int) $rxRow($id)['times_per_day'] === 3 && (int) $rxRow($id)['usage_days'] === 9
|
||||
&& (int) $orderRow($id)['medication_days'] === 8, 'Existing main and order usage updates must remain intact');
|
||||
$log = $logs($id);
|
||||
$expect(count($log) === 1 && $log[0]['action'] === 'patch_rx_usage'
|
||||
&& ((int) $count === 1 ? !str_contains($log[0]['summary'], '辅方袋数')
|
||||
: str_contains($log[0]['summary'], '辅方袋数 1袋 → ' . (int) $count . '袋'))
|
||||
&& (int) $log[0]['admin_id'] === 1 && $log[0]['admin_name'] === $admin['name']
|
||||
&& (int) $log[0]['create_time'] > 0, 'Usage log must include changed auxiliary bag count, operator and time');
|
||||
}
|
||||
|
||||
foreach ([1, 5, '3'] as $count) {
|
||||
$id = $fixture();
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => $count], 1, $admin),
|
||||
'Valid main bag count must save');
|
||||
$expect($rxModel($id)->dosage_bag_count === (int) $count
|
||||
&& $rxModel($id)->aux_usage['dosage_bag_count'] === 1 && $rxModel($id)->dosage_amount === 5.0,
|
||||
'Main bag edits must round-trip independently without altering auxiliary bags or dosage');
|
||||
$expect(str_contains($logs($id)[0]['summary'], '主方袋数 2袋 → ' . (int) $count . '袋')
|
||||
&& !str_contains($logs($id)[0]['summary'], '辅方袋数'), 'Main-only bag changes must be logged under the main formula');
|
||||
}
|
||||
|
||||
$id = $fixture();
|
||||
$allChanges = array_replace($params($id), ['times_per_day' => 4, 'dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5]);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($allChanges, 1, $admin), 'Both formula bag counts must save together');
|
||||
$expect($rxModel($id)->dosage_bag_count === 4 && $rxModel($id)->aux_usage['dosage_bag_count'] === 5,
|
||||
'Simultaneous main and auxiliary bag edits must remain independent');
|
||||
$summary = $logs($id)[0]['summary'];
|
||||
foreach (['主方每天次数 3次 → 4次', '主方开立天数 7天 → 9天', '主方袋数 2袋 → 4袋',
|
||||
'辅方每天次数 3次 → 2次', '辅方开立天数 7天 → 6天', '辅方袋数 1袋 → 5袋', '订单服用天数 7天 → 8天'] as $change) {
|
||||
$expect(str_contains($summary, $change), 'Every changed usage field must include its old and new value in the log');
|
||||
}
|
||||
$expect(count(explode(';', $summary)) === 7, 'The combined usage log must contain every change exactly once');
|
||||
|
||||
$id = $fixture();
|
||||
$before = $snapshot($id);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($unchangedParams($id) + ['dosage_bag_count' => 2, 'aux_dosage_bag_count' => 1], 1, $admin)
|
||||
&& $snapshot($id) === $before, 'An unchanged request must not rewrite rows or create an empty operation log');
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($unchangedParams($id) + ['dosage_bag_count' => 5], 1, $admin)
|
||||
&& $logs($id)[0]['summary'] === '服用参数:主方袋数 2袋 → 5袋', 'A bag-only edit must log only that actual change');
|
||||
|
||||
$id = $fixture(['aux_usage' => json_encode(array_replace($auxSeed, ['dosage_bag_count' => 4]), JSON_UNESCAPED_UNICODE)]);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id), 1, $admin), 'Legacy usage request must still save');
|
||||
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->aux_usage['dosage_bag_count'] === 4
|
||||
&& !str_contains($logs($id)[0]['summary'], '袋数'), 'Omission must preserve both previous bag counts without reporting a bag edit');
|
||||
$missingCountAux = $auxSeed;
|
||||
unset($missingCountAux['dosage_bag_count']);
|
||||
$id = $fixture(['aux_usage' => json_encode($missingCountAux, JSON_UNESCAPED_UNICODE)]);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id), 1, $admin)
|
||||
&& !array_key_exists('dosage_bag_count', $rxModel($id)->aux_usage), 'Omission must not insert a default bag count into legacy JSON');
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => 2], 1, $admin)
|
||||
&& str_contains($logs($id)[1]['summary'], '辅方袋数 1袋 → 2袋'), 'First explicit bag edit must log the displayed legacy default');
|
||||
|
||||
foreach (['饮片', '散剂'] as $type) {
|
||||
$id = $fixture(['prescription_type' => $type]);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 5, 'aux_dosage_bag_count' => 5], 1, $admin),
|
||||
'Other prescription types must retain their existing usage-edit behavior');
|
||||
$expect($rxModel($id)->dosage_bag_count === 2 && $rxModel($id)->aux_usage['dosage_bag_count'] === 1
|
||||
&& !str_contains($logs($id)[0]['summary'], '袋数'), 'Other prescription types must ignore both bag edits');
|
||||
}
|
||||
foreach ([json_encode($auxSeed, JSON_UNESCAPED_UNICODE), null] as $oldAux) {
|
||||
$id = $fixture(['herbs' => json_encode([['formula_type' => '主方']], JSON_UNESCAPED_UNICODE), 'aux_usage' => $oldAux]);
|
||||
$beforeAux = $rxRow($id)['aux_usage'];
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 5, 'aux_dosage_bag_count' => 5], 1, $admin),
|
||||
'Main-only prescription must still support its original usage changes');
|
||||
$expect($rxModel($id)->dosage_bag_count === 5 && $rxRow($id)['aux_usage'] === $beforeAux
|
||||
&& !str_contains($logs($id)[0]['summary'], '辅方袋数'), 'A main-only prescription may edit main bags without changing stale auxiliary JSON');
|
||||
}
|
||||
|
||||
// Logic callers must receive the same strict rejection even without a controller/validator.
|
||||
foreach (['dosage_bag_count' => '主方', 'aux_dosage_bag_count' => '辅方'] as $field => $label) {
|
||||
foreach ($invalidCounts as $count) {
|
||||
$id = $fixture();
|
||||
$before = $snapshot($id);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + [$field => $count], 1, $admin) === false
|
||||
&& PrescriptionOrderLogic::getError() === $label . '袋数须为 1~5 的整数' && $snapshot($id) === $before,
|
||||
'Invalid direct logic bag input must leave prescription, order and logs unchanged');
|
||||
}
|
||||
}
|
||||
foreach (['times_per_day' => 6, 'usage_days' => 999, 'medication_days' => 999, 'aux_times_per_day' => 6, 'aux_usage_days' => 999] as $field => $max) {
|
||||
foreach ([0, $max + 1, 1.5, '2invalid', true] as $invalid) {
|
||||
$id = $fixture();
|
||||
$before = $snapshot($id);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage(array_replace($params($id), [$field => $invalid]), 1, $admin) === false
|
||||
&& $snapshot($id) === $before, 'Invalid direct logic usage input must not be truncated or persisted');
|
||||
}
|
||||
}
|
||||
|
||||
$reject = static function (int $id, int $adminId, array $actor, string $error) use ($params, $snapshot, $expect): void {
|
||||
$before = $snapshot($id);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['aux_dosage_bag_count' => 5], $adminId, $actor) === false
|
||||
&& str_contains(PrescriptionOrderLogic::getError(), $error) && $snapshot($id) === $before,
|
||||
'Rejected usage mutation must preserve all persisted state: ' . $error);
|
||||
};
|
||||
$outsider = ['root' => 0, 'admin_id' => 2, 'role_id' => [], 'permissions' => [], 'name' => '无权限测试账号'];
|
||||
$reject($fixture(), 2, $outsider, '无权限操作');
|
||||
$reject($fixture([], ['creator_id' => 2]), 2, $outsider, '无权限修改此处方');
|
||||
$reject($fixture([], ['fulfillment_status' => 4, 'ej_pharmacy_order_no' => 'EJ-TEST']), 2, $outsider, '无权限操作');
|
||||
$reject($fixture(['delete_time' => time()]), 1, $admin, '处方不存在');
|
||||
|
||||
$allow = static function (int $id) use ($params, $rxModel, $orderRow, $logs, $expect, $admin): void {
|
||||
$beforeOrder = $orderRow($id);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin),
|
||||
'Usage edits must be allowed regardless of fulfillment or pharmacy submission status');
|
||||
$expect($rxModel($id)->dosage_bag_count === 4 && $rxModel($id)->aux_usage['dosage_bag_count'] === 5
|
||||
&& (int) $orderRow($id)['medication_days'] === 8 && count($logs($id)) === 1,
|
||||
'All-status edit must persist independent main/auxiliary bags, days and its operation log');
|
||||
$afterOrder = $orderRow($id);
|
||||
unset($beforeOrder['medication_days'], $beforeOrder['update_time'], $afterOrder['medication_days'], $afterOrder['update_time']);
|
||||
$expect($afterOrder === $beforeOrder, 'Usage edits must preserve fulfillment status and remote pharmacy identity');
|
||||
};
|
||||
foreach ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as $status) {
|
||||
$allow($fixture([], ['fulfillment_status' => $status]));
|
||||
}
|
||||
foreach ([['gancao_reciperl_order_no' => 'GC-TEST'], ['ej_pharmacy_order_no' => 'EJ-TEST'],
|
||||
['gancao_submit_time' => time()], ['ej_pharmacy_submit_time' => time()]] as $remoteField) {
|
||||
$id = $fixture([], $remoteField);
|
||||
$allow($id);
|
||||
$before = $snapshot($id);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionPatient($id, '测试姓名', '13000000000', 1, $admin) === false
|
||||
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
|
||||
'Opening usage edits must not open patient snapshot edits after submission');
|
||||
$expect(PrescriptionOrderLogic::edit(['id' => $id], 1, $admin) === false
|
||||
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
|
||||
'Opening usage edits must not open general order edits after submission');
|
||||
$expect(PrescriptionOrderLogic::setShipMode($id, 'direct', 1, $admin) === false
|
||||
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
|
||||
'Opening usage edits must not open pharmacy selection after submission');
|
||||
}
|
||||
foreach (['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'] as $status) {
|
||||
$id = $fixture();
|
||||
Db::name('pharmacy_submission_claim')->insert(['prescription_order_id' => $id, 'source_revision' => 1, 'status' => $status]);
|
||||
$allow($id);
|
||||
$expect(Db::name('pharmacy_submission_claim')->where('prescription_order_id', $id)->value('status') === $status,
|
||||
'Usage edits must leave the existing pharmacy submission claim untouched');
|
||||
$before = $snapshot($id);
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionPatient($id, '测试姓名', '13000000000', 1, $admin) === false
|
||||
&& str_contains(PrescriptionOrderLogic::getError(), '订单已提交药房') && $snapshot($id) === $before,
|
||||
'A pharmacy submission claim must still lock patient edits');
|
||||
}
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params(999999) + ['aux_dosage_bag_count' => 5], 1, $admin) === false
|
||||
&& PrescriptionOrderLogic::getError() === '订单不存在', 'Missing order must retain the existing rejection');
|
||||
|
||||
// Force failure after the prescription save to prove the shared order transaction rolls it back.
|
||||
$id = $fixture();
|
||||
$before = $snapshot($id);
|
||||
$pdo->exec("CREATE TRIGGER reject_aux_bag_order BEFORE UPDATE ON zyt_tcm_prescription_order
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced auxiliary bag order failure'");
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin) === false
|
||||
&& $snapshot($id) === $before, 'Order save failure must roll back the earlier prescription JSON update');
|
||||
$pdo->exec('DROP TRIGGER reject_aux_bag_order');
|
||||
|
||||
$id = $fixture([], ['fulfillment_status' => 4, 'ej_pharmacy_order_no' => 'EJ-TEST']);
|
||||
$before = $snapshot($id);
|
||||
$pdo->exec("CREATE TRIGGER reject_usage_log BEFORE INSERT ON zyt_tcm_prescription_order_log
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced usage log failure'");
|
||||
$expect(PrescriptionOrderLogic::patchPrescriptionUsage($params($id) + ['dosage_bag_count' => 4, 'aux_dosage_bag_count' => 5], 1, $admin) === false
|
||||
&& PrescriptionOrderLogic::getError() === '操作日志写入失败,变更未保存' && $snapshot($id) === $before,
|
||||
'Mandatory log failure must roll back main bags, auxiliary JSON, days and all timestamps, even on cancelled/submitted orders');
|
||||
$pdo->exec('DROP TRIGGER reject_usage_log');
|
||||
|
||||
echo "PrescriptionOrderAuxBagCountTest: {$checks} assertions passed\n";
|
||||
} finally {
|
||||
$manager->connect()->close();
|
||||
$pdo->exec("DROP DATABASE `{$database}`");
|
||||
}
|
||||
Reference in New Issue
Block a user