514 lines
26 KiB
PHP
514 lines
26 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\prescriptionai;
|
|
|
|
/**
|
|
* Pure, conservative soft-Dice comparison. No database, configuration or model calls.
|
|
*
|
|
* Catalog contract: trusted server rows {id: positive integer, name: string,
|
|
* aliases?: string[], processing?: string, dictionary_version?: string}.
|
|
* Names (including aliases) must identify exactly one catalog identity. Candidate
|
|
* medicine_id is deliberately ignored; a supplied doctor's ID must match its name.
|
|
*
|
|
* Each herb declares dosage, unit, dose_basis (per_dose/per_day), formula_type
|
|
* (主方/辅方). Explicit prescription-level unit/dose_basis/formula_type may apply
|
|
* to all rows. Only the persisted doctor's 饮片 rows may omit unit/basis (g/per_dose),
|
|
* consistent with the desktop editor's 克 and total = dosage * dose_count contract.
|
|
* The doctor's absent role defaults to 主方 under the same editor contract.
|
|
* Missing and explicit null/empty values are different: null is never a default.
|
|
*
|
|
* Unit spelling aliases are normalized, but there are NO quantity, formulation,
|
|
* per-day/per-dose or extraction-ratio conversions. Changing this requires a new
|
|
* algorithm/configuration version. usage_differences never affect the score.
|
|
*/
|
|
final class PrescriptionAiComparison
|
|
{
|
|
// v1.1.0: the doctor's prescription-level 用量单位/剂量单位 are read as declared values, and a
|
|
// processing label already carried by the medicine name no longer splits one identity.
|
|
public const ALGORITHM_VERSION = 'prescription-soft-dice-v1.1.0';
|
|
|
|
private const FORMULATIONS = ['饮片', '颗粒', '浓缩水丸', '丸剂', '散剂', '膏方', '汤剂'];
|
|
|
|
private const USAGE_FIELDS = [
|
|
'dose_count', 'usage_days', 'times_per_day', 'dosage_amount', 'dosage_unit',
|
|
'dosage_bag_count', 'usage_instruction', 'usage_time', 'usage_way', 'aux_usage',
|
|
];
|
|
|
|
private const ROW_USAGE_FIELDS = [
|
|
'usage_instruction', 'decoction_instruction', 'special_usage', 'usage_time', 'usage_way', 'instructions',
|
|
];
|
|
|
|
public static function compare(array $doctor, array $candidate, array $catalog = []): array
|
|
{
|
|
$dictionary = self::buildDictionary($catalog);
|
|
$doctorResult = self::normalize($doctor, 'doctor', $dictionary);
|
|
$candidateResult = self::normalize($candidate, 'candidate', $dictionary);
|
|
$issues = array_merge($doctorResult['issues'], $candidateResult['issues']);
|
|
$usageDifferences = self::usageDifferences($doctor, $candidate);
|
|
|
|
if ($doctorResult['formulation'] !== $candidateResult['formulation']) {
|
|
$issues[] = self::issue('both', null, 'formulation_mismatch', '剂型不同,未提供经确认的换算规则');
|
|
}
|
|
$allBases = array_unique(array_merge($doctorResult['bases'], $candidateResult['bases']));
|
|
if (count($allBases) > 1) {
|
|
$issues[] = self::issue('both', null, 'dose_basis_mismatch', '剂量基准不同,不能按每剂与每日直接比较');
|
|
}
|
|
|
|
$doctorItems = $doctorResult['items'];
|
|
$candidateItems = $candidateResult['items'];
|
|
$keys = array_values(array_unique(array_merge(array_keys($doctorItems), array_keys($candidateItems))));
|
|
sort($keys, SORT_STRING);
|
|
$rows = [];
|
|
$matchedCount = 0;
|
|
$contribution = 0.0;
|
|
foreach ($keys as $key) {
|
|
$left = $doctorItems[$key] ?? null;
|
|
$right = $candidateItems[$key] ?? null;
|
|
$identity = $left ?? $right;
|
|
$ratio = null;
|
|
if ($left !== null && $right !== null) {
|
|
$matchedCount++;
|
|
if ($left['unit'] !== $right['unit']) {
|
|
$issues[] = self::issue('both', null, 'unit_mismatch', $identity['name'] . '的剂量单位不同,未执行单位换算');
|
|
} elseif ($left['dosage'] !== null && $right['dosage'] !== null
|
|
&& $left['dose_basis'] === $right['dose_basis']) {
|
|
$ratio = min($left['dosage'], $right['dosage']) / max($left['dosage'], $right['dosage']);
|
|
$contribution += $ratio;
|
|
}
|
|
if ($left['usage'] !== $right['usage']) {
|
|
$usageDifferences[] = [
|
|
'field' => 'herb_usage', 'key' => $key, 'name' => $identity['name'],
|
|
'doctor' => $left['usage'], 'candidate' => $right['usage'],
|
|
];
|
|
}
|
|
if ($left['declared_processing'] !== $right['declared_processing']) {
|
|
$usageDifferences[] = [
|
|
'field' => 'herb_processing_label', 'key' => $key, 'name' => $identity['name'],
|
|
'doctor' => $left['declared_processing'], 'candidate' => $right['declared_processing'],
|
|
];
|
|
}
|
|
}
|
|
$rows[] = [
|
|
'key' => $key,
|
|
'medicine_id' => $identity['medicine_id'],
|
|
'name' => $identity['name'],
|
|
'processing' => $identity['processing'],
|
|
'formula_type' => $identity['formula_type'],
|
|
'administration_route' => $identity['administration_route'],
|
|
'group' => $identity['group'],
|
|
'doctor' => $left,
|
|
'candidate' => $right,
|
|
'doctor_dosage' => $left['dosage'] ?? null,
|
|
'candidate_dosage' => $right['dosage'] ?? null,
|
|
'unit' => $identity['unit'],
|
|
'dose_basis' => $identity['dose_basis'],
|
|
'match_type' => $left === null ? 'candidate_only' : ($right === null ? 'doctor_only' : 'matched'),
|
|
'contribution' => $ratio,
|
|
];
|
|
}
|
|
|
|
$denominator = count($doctorItems) + count($candidateItems);
|
|
$identityComplete = $doctorResult['identity_complete'] && $candidateResult['identity_complete'];
|
|
$herbScore = $identityComplete && $denominator > 0
|
|
&& $doctorItems !== [] && $candidateItems !== []
|
|
? 100.0 * 2.0 * $matchedCount / $denominator : null;
|
|
$comparable = $issues === [];
|
|
if (!$comparable) {
|
|
// Partial row ratios must never look like a complete, usable score.
|
|
foreach ($rows as &$row) {
|
|
$row['contribution'] = null;
|
|
}
|
|
unset($row);
|
|
}
|
|
|
|
return [
|
|
'status' => $comparable ? 'comparable' : 'not_comparable',
|
|
'score' => $comparable ? min(100.0, max(0.0, 100.0 * 2.0 * $contribution / $denominator)) : null,
|
|
'herb_score' => $herbScore,
|
|
'reason_code' => $comparable ? 'ok' : $issues[0]['code'],
|
|
'reason' => $comparable ? '药味与剂量可比;服法、疗程与风险须独立复核' : $issues[0]['reason'],
|
|
'algorithm_version' => self::ALGORITHM_VERSION,
|
|
'doctor_count' => count($doctorItems),
|
|
'candidate_count' => count($candidateItems),
|
|
'matched_count' => $matchedCount,
|
|
'rows' => $rows,
|
|
'usage_differences' => $usageDifferences,
|
|
'normalization' => [
|
|
'doctor' => $doctorResult,
|
|
'candidate' => $candidateResult,
|
|
'issues' => $issues,
|
|
'dictionary_hash' => $dictionary['hash'],
|
|
'dictionary_versions' => $dictionary['versions'],
|
|
'unit_policy' => 'spelling_aliases_only_no_quantity_conversion',
|
|
'denominator' => $denominator,
|
|
'matched_contribution_sum' => $comparable ? $contribution : null,
|
|
],
|
|
];
|
|
}
|
|
|
|
private static function normalize(array $prescription, string $side, array $dictionary): array
|
|
{
|
|
$result = [
|
|
'formulation' => self::formulation($prescription['prescription_type'] ?? null),
|
|
'items' => [], 'bases' => [], 'issues' => [], 'merges' => [],
|
|
'defaults' => [], 'identity_complete' => true, 'raw_herb_count' => 0,
|
|
];
|
|
$status = self::text($prescription['status'] ?? null);
|
|
$blockedStatuses = [
|
|
'insufficient_data' => '资料不足,未形成可比候选方案',
|
|
'withheld_for_risk' => '因风险暂缓提供用药方案',
|
|
'no_medication' => '明确建议暂不使用药物,须单列用药决策差异',
|
|
'no_medication_recommended' => '明确建议暂不使用药物,须单列用药决策差异',
|
|
'failed' => '模型生成失败', 'error' => '模型生成失败',
|
|
];
|
|
if (isset($blockedStatuses[$status])) {
|
|
$result['issues'][] = self::issue($side, null, $status, $blockedStatuses[$status]);
|
|
}
|
|
if (!in_array($result['formulation'], self::FORMULATIONS, true)) {
|
|
$result['issues'][] = self::issue($side, null, 'unknown_formulation', '剂型缺失或不受支持');
|
|
}
|
|
$herbs = $prescription['herbs'] ?? null;
|
|
if (!is_array($herbs) || $herbs === []) {
|
|
$result['issues'][] = self::issue($side, null, 'empty_prescription', '处方为空或药味结构无效');
|
|
$result['identity_complete'] = false;
|
|
return $result;
|
|
}
|
|
$result['raw_herb_count'] = count($herbs);
|
|
if ($dictionary['names'] === []) {
|
|
$result['issues'][] = self::issue($side, null, 'catalog_unavailable', '缺少可用的服务端药材字典');
|
|
}
|
|
|
|
foreach (array_values($herbs) as $index => $herb) {
|
|
if (!is_array($herb)) {
|
|
$result['issues'][] = self::issue($side, $index, 'invalid_herb', '药味必须为结构化对象');
|
|
$result['identity_complete'] = false;
|
|
continue;
|
|
}
|
|
$name = self::text($herb['name'] ?? null);
|
|
$identities = $dictionary['names'][$name] ?? [];
|
|
if ($name === '' || count($identities) !== 1) {
|
|
$code = count($identities) > 1 ? 'ambiguous_herb_name' : 'unknown_herb_name';
|
|
$result['issues'][] = self::issue($side, $index, $code, '药名“' . $name . '”无法唯一映射服务端字典');
|
|
$result['identity_complete'] = false;
|
|
continue;
|
|
}
|
|
$entry = $dictionary['entries'][array_key_first($identities)];
|
|
if ($side === 'doctor' && array_key_exists('medicine_id', $herb)
|
|
&& self::positiveId($herb['medicine_id']) !== $entry['id']) {
|
|
$result['issues'][] = self::issue($side, $index, 'doctor_identity_mismatch', '医生药材 ID 与规范药名不一致');
|
|
$result['identity_complete'] = false;
|
|
continue;
|
|
}
|
|
$processing = array_key_exists('processing', $herb) ? self::text($herb['processing']) : $entry['processing'];
|
|
if (in_array(strtolower($processing), ['无', '明确无', '无额外炮制', 'none'], true)) {
|
|
$processing = '';
|
|
}
|
|
if ((array_key_exists('processing', $herb) && !is_string($herb['processing']))
|
|
|| in_array(strtolower($processing), ['未知', '不详', 'unknown'], true)
|
|
|| ($entry['processing'] !== '' && $processing !== $entry['processing'])) {
|
|
$result['issues'][] = self::issue($side, $index, 'processing_conflict', '炮制信息与药材字典冲突或无效');
|
|
$result['identity_complete'] = false;
|
|
continue;
|
|
}
|
|
$roleValue = self::declaredValue($herb, $prescription, 'formula_type');
|
|
if (!$roleValue['present'] && $side === 'doctor') {
|
|
$roleValue['value'] = '主方';
|
|
$result['defaults'][] = ['row' => $index, 'field' => 'formula_type', 'value' => '主方'];
|
|
}
|
|
$role = self::role($roleValue['value']);
|
|
$route = self::text($herb['administration_route'] ?? '');
|
|
$group = self::text($herb['group'] ?? '');
|
|
if ($role === '' || (array_key_exists('administration_route', $herb) && !is_string($herb['administration_route']))
|
|
|| (array_key_exists('group', $herb) && !is_string($herb['group']))) {
|
|
$result['issues'][] = self::issue($side, $index, 'ambiguous_herb_role', '主辅方、给药路径或分组语义不明确');
|
|
$result['identity_complete'] = false;
|
|
continue;
|
|
}
|
|
$unitValue = self::declaredValue($herb, $prescription, 'unit');
|
|
$basisValue = self::declaredValue($herb, $prescription, 'dose_basis');
|
|
if ($side === 'doctor') {
|
|
// The workstation stores the per-herb unit once as the prescription's 用量单位
|
|
// (dosage_unit) and declares the dose basis through 剂量单位=剂/付. Both are the
|
|
// doctor's own explicit values; nothing is guessed when they are absent.
|
|
$declaredUnit = self::unit($prescription['dosage_unit'] ?? null);
|
|
$perDose = in_array(self::text($prescription['dose_unit'] ?? null), ['剂', '付'], true);
|
|
$fallbacks = [];
|
|
if ($declaredUnit !== '') {
|
|
$fallbacks['unit'] = $declaredUnit;
|
|
} elseif ($result['formulation'] === '饮片') {
|
|
$fallbacks['unit'] = 'g';
|
|
}
|
|
if ($perDose || $result['formulation'] === '饮片') {
|
|
$fallbacks['dose_basis'] = 'per_dose';
|
|
}
|
|
foreach ($fallbacks as $field => $default) {
|
|
$value = $field === 'unit' ? $unitValue : $basisValue;
|
|
if (!$value['present']) {
|
|
$result['defaults'][] = ['row' => $index, 'field' => $field, 'value' => $default];
|
|
if ($field === 'unit') {
|
|
$unitValue['value'] = $default;
|
|
} else {
|
|
$basisValue['value'] = $default;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$unit = self::unit($unitValue['value']);
|
|
$basis = self::basis($basisValue['value']);
|
|
if ($unit === '') {
|
|
$result['issues'][] = self::issue($side, $index, 'missing_or_unknown_unit', '剂量单位缺失或不受支持');
|
|
}
|
|
if ($basis === '') {
|
|
$result['issues'][] = self::issue($side, $index, 'missing_or_unknown_dose_basis', '须明确每剂或每日剂量基准');
|
|
} else {
|
|
$result['bases'][] = $basis;
|
|
}
|
|
$dosage = self::positiveNumber($herb['dosage'] ?? null);
|
|
if ($dosage === null) {
|
|
$result['issues'][] = self::issue($side, $index, 'invalid_dosage', '剂量必须为有限、明确且大于零的数值');
|
|
}
|
|
$usage = [];
|
|
foreach (self::ROW_USAGE_FIELDS as $field) {
|
|
$value = $herb[$field] ?? '';
|
|
if (!is_string($value)) {
|
|
$result['issues'][] = self::issue($side, $index, 'invalid_herb_usage', '药味煎服说明必须为文本');
|
|
}
|
|
$usage[$field] = in_array(strtolower(self::text($value)), ['无', '明确无', 'none'], true) ? '' : self::text($value);
|
|
}
|
|
// Identity processing comes from the dictionary. When the institution already encodes
|
|
// the processed form in the medicine name (醋五味子 + "醋制", 麸炒白术 + "麸炒"), the
|
|
// written processing only restates the name: it is kept for display and difference
|
|
// reporting but must not split one medicine into two rows. A processing that the name
|
|
// does not carry (黄芪 + "蜜炙") stays a distinct medication item.
|
|
$identityProcessing = $entry['processing'] !== '' || !self::restatesName($processing, $entry['name'])
|
|
? $processing : '';
|
|
$key = json_encode([$entry['id'], $identityProcessing, $role, $route, $group], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
$normalized = [
|
|
'medicine_id' => $entry['id'], 'name' => $entry['name'], 'processing' => $identityProcessing,
|
|
'declared_processing' => $processing,
|
|
'formula_type' => $role, 'administration_route' => $route, 'group' => $group,
|
|
'dosage' => $dosage, 'unit' => $unit, 'dose_basis' => $basis, 'usage' => $usage,
|
|
'source_rows' => [$index], 'source_names' => [$name],
|
|
'original_dosages' => [self::auditNumber($herb['dosage'] ?? null)],
|
|
];
|
|
if (isset($result['items'][$key])) {
|
|
$prior = $result['items'][$key];
|
|
if ($prior['unit'] !== $unit || $prior['dose_basis'] !== $basis || $prior['usage'] !== $usage
|
|
|| $prior['declared_processing'] !== $processing) {
|
|
$result['issues'][] = self::issue($side, $index, 'duplicate_semantics_conflict', '同药项重复行的单位、基准或煎服语义不一致,不能合并');
|
|
$result['identity_complete'] = false;
|
|
continue;
|
|
}
|
|
$merged = $prior['dosage'] === null || $dosage === null ? null : $prior['dosage'] + $dosage;
|
|
if ($merged !== null && !is_finite($merged)) {
|
|
$result['issues'][] = self::issue($side, $index, 'invalid_dosage', '重复药项合并后剂量不是有限数值');
|
|
$merged = null;
|
|
}
|
|
$normalized['dosage'] = $merged;
|
|
foreach (['source_rows', 'source_names', 'original_dosages'] as $field) {
|
|
$normalized[$field] = array_merge($prior[$field], $normalized[$field]);
|
|
}
|
|
$result['merges'][] = ['key' => $key, 'source_rows' => $normalized['source_rows'], 'dosage' => $merged];
|
|
}
|
|
$result['items'][$key] = $normalized;
|
|
}
|
|
$result['bases'] = array_values(array_unique($result['bases']));
|
|
return $result;
|
|
}
|
|
|
|
private static function buildDictionary(array $catalog): array
|
|
{
|
|
$result = ['entries' => [], 'names' => [], 'versions' => []];
|
|
foreach ($catalog as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
$id = self::positiveId($row['id'] ?? null);
|
|
$name = self::text($row['name'] ?? null);
|
|
if ($id === null || $name === '') {
|
|
continue;
|
|
}
|
|
$processing = self::text($row['processing'] ?? '');
|
|
$entryKey = json_encode([$id, $name, $processing], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
$result['entries'][$entryKey] = ['id' => $id, 'name' => $name, 'processing' => $processing];
|
|
$aliases = isset($row['aliases']) && is_array($row['aliases']) ? $row['aliases'] : [];
|
|
foreach (array_merge([$name], $aliases) as $alias) {
|
|
$alias = self::text($alias);
|
|
if ($alias !== '') {
|
|
$result['names'][$alias][$entryKey] = true;
|
|
}
|
|
}
|
|
$version = self::text($row['dictionary_version'] ?? '');
|
|
if ($version !== '') {
|
|
$result['versions'][] = $version;
|
|
}
|
|
}
|
|
// A reused ID with different canonical identities is corrupt even when the
|
|
// names themselves differ: matching by the resulting ID would forge overlap.
|
|
$ids = [];
|
|
foreach ($result['entries'] as $entryKey => $entry) {
|
|
$ids[$entry['id']][$entryKey] = true;
|
|
}
|
|
foreach ($result['names'] as &$identities) {
|
|
foreach (array_keys($identities) as $entryKey) {
|
|
$identities += $ids[$result['entries'][$entryKey]['id']];
|
|
}
|
|
ksort($identities, SORT_STRING);
|
|
}
|
|
unset($identities);
|
|
ksort($result['entries'], SORT_STRING);
|
|
ksort($result['names'], SORT_STRING);
|
|
$result['versions'] = array_values(array_unique($result['versions']));
|
|
sort($result['versions'], SORT_STRING);
|
|
$result['hash'] = hash('sha256', json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
return $result;
|
|
}
|
|
|
|
private static function declaredValue(array $row, array $prescription, string $field): array
|
|
{
|
|
if (array_key_exists($field, $row)) {
|
|
return ['present' => true, 'value' => $row[$field]];
|
|
}
|
|
return ['present' => array_key_exists($field, $prescription), 'value' => $prescription[$field] ?? null];
|
|
}
|
|
|
|
private static function role($value): string
|
|
{
|
|
$roles = ['主方' => '主方', 'main' => '主方', 'primary' => '主方', '1' => '主方',
|
|
'辅方' => '辅方', 'aux' => '辅方', 'auxiliary' => '辅方', 'secondary' => '辅方', '2' => '辅方'];
|
|
return $roles[self::text($value)] ?? '';
|
|
}
|
|
|
|
/** True when every character of a processing label already appears in the medicine name. */
|
|
private static function restatesName(string $processing, string $name): bool
|
|
{
|
|
$label = str_replace(['制', '品', '法', '的'], '', self::text($processing));
|
|
if ($label === '' || $name === '') {
|
|
return false;
|
|
}
|
|
foreach (preg_split('//u', $label, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $character) {
|
|
if (mb_strpos($name, $character) === false) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Spelling aliases only. Different dosage forms are never merged: 颗粒 and 浓缩水丸 stay
|
|
* distinct, and no quantity or extraction-ratio conversion is implied.
|
|
*/
|
|
private static function formulation($value): string
|
|
{
|
|
$text = self::text($value);
|
|
$aliases = ['中药饮片' => '饮片', '草药' => '饮片', '中药配方颗粒' => '颗粒', '配方颗粒' => '颗粒',
|
|
'免煎颗粒' => '颗粒', '中药颗粒' => '颗粒', '汤药' => '汤剂', '中药汤剂' => '汤剂'];
|
|
return $aliases[$text] ?? $text;
|
|
}
|
|
|
|
private static function unit($value): string
|
|
{
|
|
$units = ['g' => 'g', '克' => 'g', 'mg' => 'mg', '毫克' => 'mg', 'kg' => 'kg', '千克' => 'kg',
|
|
'ml' => 'ml', '毫升' => 'ml', '片' => '片', '粒' => '粒', '丸' => '丸', '袋' => '袋'];
|
|
return $units[strtolower(self::text($value))] ?? '';
|
|
}
|
|
|
|
private static function basis($value): string
|
|
{
|
|
$bases = ['per_dose' => 'per_dose', '每剂' => 'per_dose', 'per_day' => 'per_day', '每日' => 'per_day'];
|
|
return $bases[self::text($value)] ?? '';
|
|
}
|
|
|
|
private static function text($value): string
|
|
{
|
|
if (!is_string($value) || preg_match('//u', $value) !== 1) {
|
|
return '';
|
|
}
|
|
return preg_replace('/^[\s\x{3000}]+|[\s\x{3000}]+$/u', '', $value) ?? '';
|
|
}
|
|
|
|
private static function positiveId($value): ?int
|
|
{
|
|
if ((!is_int($value) && !is_string($value)) || !preg_match('/^[1-9][0-9]*$/D', (string) $value)) {
|
|
return null;
|
|
}
|
|
$id = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
|
return $id === false ? null : $id;
|
|
}
|
|
|
|
private static function positiveNumber($value): ?float
|
|
{
|
|
if ((!is_int($value) && !is_float($value) && !is_string($value)) || !is_numeric($value)) {
|
|
return null;
|
|
}
|
|
$number = (float) $value;
|
|
return is_finite($number) && $number > 0.0 ? $number : null;
|
|
}
|
|
|
|
private static function auditNumber($value)
|
|
{
|
|
if (is_float($value) && !is_finite($value)) {
|
|
return is_nan($value) ? 'NaN' : ($value > 0 ? 'Infinity' : '-Infinity');
|
|
}
|
|
return is_scalar($value) || $value === null ? $value : '[invalid non-scalar]';
|
|
}
|
|
|
|
private static function issue(string $side, ?int $row, string $code, string $reason): array
|
|
{
|
|
return ['side' => $side, 'row' => $row, 'code' => $code, 'reason' => $reason];
|
|
}
|
|
|
|
private static function usageDifferences(array $doctor, array $candidate): array
|
|
{
|
|
$doctor = self::usageView($doctor);
|
|
$candidate = self::usageView($candidate);
|
|
$differences = [];
|
|
foreach (self::USAGE_FIELDS as $field) {
|
|
$left = $doctor[$field] ?? null;
|
|
$right = $candidate[$field] ?? null;
|
|
if (self::stableValue($left) !== self::stableValue($right)) {
|
|
$differences[] = ['field' => $field, 'doctor' => $left, 'candidate' => $right];
|
|
}
|
|
}
|
|
return $differences;
|
|
}
|
|
|
|
/**
|
|
* Display-only: a side that states one unanimous per-herb unit also states it as the
|
|
* prescription's 用量单位. Never used for identity, dosage or scoring.
|
|
*/
|
|
private static function usageView(array $prescription): array
|
|
{
|
|
if (self::text($prescription['dosage_unit'] ?? null) !== '' || !is_array($prescription['herbs'] ?? null)) {
|
|
return $prescription;
|
|
}
|
|
$units = [];
|
|
foreach ($prescription['herbs'] as $herb) {
|
|
$units[] = is_array($herb) ? self::unit($herb['unit'] ?? null) : '';
|
|
}
|
|
$units = array_values(array_unique($units));
|
|
if (count($units) === 1 && $units[0] !== '') {
|
|
$prescription['dosage_unit'] = $units[0];
|
|
}
|
|
return $prescription;
|
|
}
|
|
|
|
private static function stableValue($value)
|
|
{
|
|
if (is_array($value)) {
|
|
ksort($value);
|
|
return array_map([self::class, 'stableValue'], $value);
|
|
}
|
|
if (is_string($value)) {
|
|
$value = self::text($value);
|
|
}
|
|
// Database numeric strings and the same JSON number are equivalent usage.
|
|
if ((is_string($value) || is_int($value) || is_float($value)) && is_numeric($value)) {
|
|
return self::auditNumber((float) $value);
|
|
}
|
|
return $value;
|
|
}
|
|
}
|