Files
zyt/server/tests/pharmacy/run.php
T
2026-07-22 14:50:34 +08:00

1600 lines
76 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__, 2) . '/vendor/autoload.php';
use app\common\service\pharmacy\EjPharmacyPayload;
use app\common\service\pharmacy\EjPharmacySignature;
use app\common\service\pharmacy\EjMedicineCatalogSyncPolicy;
use app\common\service\pharmacy\EjMedicineCatalogSyncWorkflow;
use app\common\service\pharmacy\EjMedicineMappingPolicy;
use app\common\service\pharmacy\EjMedicineBootstrapItem;
use app\common\service\pharmacy\EjMedicineBootstrapService;
use app\common\service\pharmacy\EjPharmacyClient;
use app\common\service\pharmacy\PharmacySubmissionClaimWorkflow;
use app\common\service\pharmacy\PharmacyRemoteRejectedException;
use app\common\service\pharmacy\PharmacyReconciliationRequiredException;
use app\common\service\pharmacy\PharmacyRemoteOutcomeClassifier;
use app\common\service\pharmacy\PharmacySubmissionClaimPolicy;
use app\common\service\pharmacy\PharmacySubmissionReconciliationPolicy;
use app\common\service\pharmacy\PharmacySupplyMode;
use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
use app\common\service\pharmacy\PharmacyRemoteSnapshotPolicy;
use app\common\service\pharmacy\EjPharmacyCallbackWorkflow;
use app\common\service\pharmacy\EjPharmacyCallbackRetryException;
use app\common\service\pharmacy\PharmacyLogisticsValue;
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
use app\adminapi\http\middleware\AuthMiddleware;
$passed = 0;
$assertSame = static function (mixed $expected, mixed $actual, string $message) use (&$passed): void {
if ($expected !== $actual) {
throw new RuntimeException($message . '\nExpected: ' . var_export($expected, true) . '\nActual: ' . var_export($actual, true));
}
++$passed;
};
$assertTrue = static function (bool $actual, string $message) use (&$passed): void {
if (!$actual) {
throw new RuntimeException($message);
}
++$passed;
};
$body = '{"source_order_no":"PO001"}';
$canonical = EjPharmacySignature::canonical('POST', '/api/openapi/v1/prescription-orders', '1784563200', 'nonce-1', $body);
$assertSame(
"POST\n/api/openapi/v1/prescription-orders\n1784563200\nnonce-1\n" . hash('sha256', $body),
$canonical,
'zyt signature canonical text must match ej'
);
$signed = EjPharmacySignature::sign('secret', $canonical);
$assertTrue(EjPharmacySignature::verify('secret', $canonical, $signed), 'zyt HMAC must verify');
$claimState = null;
$competingTargetBlocked = false;
$claimWorkflow = null;
$claimWorkflow = new PharmacySubmissionClaimWorkflow(
static function (string $target) use (&$claimState): array {
if (is_array($claimState) && $claimState['status'] === 'PENDING') {
throw new DomainException('该订单正在上传药房');
}
$claimState = ['target' => $target, 'token' => 'token-direct', 'status' => 'PENDING'];
return $claimState + ['idempotent' => false];
},
static function (string $target) use (&$claimWorkflow, &$competingTargetBlocked): array {
try {
$claimWorkflow->execute($target === 'direct' ? 'gancao' : 'direct');
} catch (DomainException $exception) {
$competingTargetBlocked = str_contains($exception->getMessage(), '正在上传');
}
return ['remote_order_no' => 'EJ-1'];
},
static function (string $target, string $token) use (&$claimState): bool {
if (!is_array($claimState) || $claimState['target'] !== $target || $claimState['token'] !== $token) {
return false;
}
$claimState['status'] = 'SUCCESS';
return true;
},
static function (): bool { return false; }
,
static function (): bool { return false; }
);
$claimResult = $claimWorkflow->execute('direct');
$assertSame(true, $competingTargetBlocked, 'one order revision must not concurrently claim two pharmacy targets');
$assertSame('SUCCESS', $claimState['status'], 'winning pharmacy claim must complete successfully');
$assertSame('EJ-1', $claimResult['remote_order_no'], 'claim workflow must return the remote result');
$claimTokenShapeSeen = '';
$claimTokenShapeWorkflow = new PharmacySubmissionClaimWorkflow(
static fn (): array => [
'target' => 'direct',
'claim_token' => 'persisted-claim-token',
'status' => 'PENDING',
'idempotent' => false,
],
static function (string $target, string $token) use (&$claimTokenShapeSeen): array {
$claimTokenShapeSeen = $token;
return ['remote_order_no' => 'EJ-TOKEN-SHAPE'];
},
static fn (): bool => true,
static fn (): bool => false,
static fn (): bool => false
);
$claimTokenShapeWorkflow->execute('direct');
$assertSame(
'persisted-claim-token',
$claimTokenShapeSeen,
'claim workflow must accept the persisted claim_token shape returned by a fresh production claim'
);
$retryState = null;
$attempt = 0;
$retryWorkflow = new PharmacySubmissionClaimWorkflow(
static function (string $target) use (&$retryState): array {
if (is_array($retryState) && in_array($retryState['status'], ['PENDING', 'SUCCESS'], true)) {
throw new DomainException('该订单已有有效药房提交');
}
$retryState = [
'target' => $target,
'token' => 'token-' . $target,
'status' => 'PENDING',
'idempotent' => false,
];
return $retryState;
},
static function (string $target) use (&$attempt): array {
++$attempt;
if ($attempt === 1) {
throw new PharmacyRemoteRejectedException('远端明确拒绝');
}
return ['remote_order_no' => strtoupper($target) . '-2'];
},
static function (string $target, string $token) use (&$retryState): bool {
if ($retryState['target'] !== $target || $retryState['token'] !== $token || $retryState['status'] !== 'PENDING') {
return false;
}
$retryState['status'] = 'SUCCESS';
return true;
},
static function (string $target, string $token) use (&$retryState): bool {
if ($retryState['target'] !== $target || $retryState['token'] !== $token || $retryState['status'] !== 'PENDING') {
return false;
}
$retryState['status'] = 'FAILED';
return true;
},
static function (): bool { return false; }
);
try {
$retryWorkflow->execute('direct');
throw new RuntimeException('confirmed rejection unexpectedly succeeded');
} catch (RuntimeException $exception) {
$assertSame('远端明确拒绝', $exception->getMessage(), 'confirmed rejection must propagate after marking claim failed');
}
$assertSame('FAILED', $retryState['status'], 'confirmed no-create rejection may release the revision for a controlled retry');
$retryResult = $retryWorkflow->execute('gancao');
$assertSame('SUCCESS', $retryState['status'], 'failed claim may retry using the newly selected pharmacy target');
$assertSame('GANCAO-2', $retryResult['remote_order_no'], 'retry must return the second target result');
$staleFailureMarked = 0;
$staleReconcileMarked = 0;
$staleWorkflow = new PharmacySubmissionClaimWorkflow(
static fn (): array => ['target' => 'direct', 'token' => 'stale-token', 'status' => 'PENDING'],
static fn (): array => ['remote_order_no' => 'EJ-STALE'],
static fn (): bool => false,
static function () use (&$staleFailureMarked): bool { ++$staleFailureMarked; return false; },
static function () use (&$staleReconcileMarked): bool { ++$staleReconcileMarked; return true; }
);
try {
$staleWorkflow->execute('direct');
throw new RuntimeException('stale claim unexpectedly overwrote submission state');
} catch (PharmacyReconciliationRequiredException $exception) {
$assertTrue(str_contains($exception->getMessage(), '对账'), 'stale token/target CAS must require reconciliation');
}
$assertSame(0, $staleFailureMarked, 'remote success with local finalize failure must never be released as failed');
$assertSame(1, $staleReconcileMarked, 'remote success with local finalize failure must enter reconciliation');
$uncertainState = ['target' => 'direct', 'token' => 'uncertain-token', 'status' => 'PENDING'];
$uncertainFailureMarked = 0;
$uncertainReconcileMarked = 0;
$uncertainWorkflow = new PharmacySubmissionClaimWorkflow(
static fn (): array => $uncertainState,
static function (): array { throw new RuntimeException('connect timeout after request send'); },
static fn (): bool => true,
static function () use (&$uncertainFailureMarked): bool { ++$uncertainFailureMarked; return true; },
static function () use (&$uncertainReconcileMarked, &$uncertainState): bool {
++$uncertainReconcileMarked;
$uncertainState['status'] = 'PENDING_RECONCILE';
return true;
}
);
try {
$uncertainWorkflow->execute('direct');
throw new RuntimeException('uncertain remote outcome unexpectedly succeeded');
} catch (RuntimeException $exception) {
$assertTrue(str_contains($exception->getMessage(), 'timeout'), 'transport uncertainty must propagate for operator visibility');
}
$assertSame(0, $uncertainFailureMarked, 'transport uncertainty must never release the revision as failed');
$assertSame(1, $uncertainReconcileMarked, 'transport uncertainty must enter reconciliation exactly once');
$assertSame('PENDING_RECONCILE', $uncertainState['status'], 'uncertain claim must remain active for same-target reconciliation');
$gancaoLostResponseRemoteCalls = 0;
$gancaoLostResponseSuccessMarks = 0;
$gancaoLostResponseFailureMarks = 0;
$gancaoLostResponseReconcileMarks = 0;
$gancaoLostResponseWorkflow = new PharmacySubmissionClaimWorkflow(
static fn (): array => [
'target' => 'gancao',
'claim_token' => 'gancao-lost-response-token',
'status' => 'PENDING_RECONCILE',
'reconcile' => true,
'idempotent' => false,
],
static function () use (&$gancaoLostResponseRemoteCalls): array {
++$gancaoLostResponseRemoteCalls;
return ['remote_order_no' => 'MUST-NOT-SUBMIT'];
},
static function () use (&$gancaoLostResponseSuccessMarks): bool {
++$gancaoLostResponseSuccessMarks;
return true;
},
static function () use (&$gancaoLostResponseFailureMarks): bool {
++$gancaoLostResponseFailureMarks;
return true;
},
static function () use (&$gancaoLostResponseReconcileMarks): bool {
++$gancaoLostResponseReconcileMarks;
return true;
}
);
try {
$gancaoLostResponseWorkflow->execute('gancao');
throw new RuntimeException('Gancao lost-response retry unexpectedly submitted again');
} catch (PharmacyReconciliationRequiredException $exception) {
$assertTrue(
str_contains($exception->getMessage(), '结果待核对') && str_contains($exception->getMessage(), '禁止重提'),
'Gancao reconciliation claim must return an actionable no-resubmit error'
);
}
$assertSame(0, $gancaoLostResponseRemoteCalls, 'Gancao lost-response retry must not invoke any remote submit path');
$assertSame(0, $gancaoLostResponseSuccessMarks, 'blocked Gancao reconciliation must not finalize success');
$assertSame(0, $gancaoLostResponseFailureMarks, 'blocked Gancao reconciliation must never release the claim as failed');
$assertSame(0, $gancaoLostResponseReconcileMarks, 'already-reconciling Gancao claim must remain unchanged');
$assertSame(
true,
PharmacyRemoteOutcomeClassifier::isConfirmedEjNoCreateHttpStatus(422),
'explicit EJ validation rejection may release a claim for controlled retry'
);
foreach ([0, 302, 408, 409, 425, 429, 499, 500, 503] as $uncertainHttpStatus) {
$assertSame(
false,
PharmacyRemoteOutcomeClassifier::isConfirmedEjNoCreateHttpStatus($uncertainHttpStatus),
"EJ HTTP {$uncertainHttpStatus} must remain pending reconciliation"
);
}
$sameTargetReconcile = PharmacySubmissionClaimPolicy::existingDecision(
[
'target' => 'direct',
'status' => 'PENDING_RECONCILE',
'claim_token' => 'stable-token',
'idempotency_key' => 'stable-key',
],
'direct'
);
$assertSame('RECONCILE', $sameTargetReconcile['action'], 'uncertain claim may only continue through same-target reconciliation');
$assertSame('stable-token', $sameTargetReconcile['claim_token'], 'same-target reconciliation must preserve claim token');
$assertSame('stable-key', $sameTargetReconcile['idempotency_key'], 'same-target reconciliation must preserve idempotency key');
$now = 1_800_000_000;
try {
PharmacySubmissionClaimPolicy::existingDecision(
[
'target' => 'direct',
'status' => 'PENDING',
'claim_token' => 'live-token',
'lease_expires_at' => $now + 30,
],
'direct',
$now
);
throw new RuntimeException('a live submission lease unexpectedly allowed another claimant');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '正在上传'), 'a live submission lease must fence competing workers');
}
$expiredDirectDecision = PharmacySubmissionClaimPolicy::existingDecision(
[
'target' => 'direct',
'status' => 'PENDING',
'claim_token' => 'stale-direct-token',
'idempotency_key' => 'stable-direct-key',
'lease_expires_at' => $now - 1,
],
'direct',
$now
);
$assertSame('RETRY', $expiredDirectDecision['action'], 'an expired EJ lease must be reclaimable');
$assertSame(true, $expiredDirectDecision['lease_expired'], 'an expired EJ lease must be marked for token rotation/fencing');
$assertSame('stable-direct-key', $expiredDirectDecision['idempotency_key'], 'EJ lease recovery must preserve remote idempotency key');
$expiredGancaoDecision = PharmacySubmissionClaimPolicy::existingDecision(
[
'target' => 'gancao',
'status' => 'PENDING',
'claim_token' => 'stale-gancao-token',
'lease_expires_at' => $now - 1,
],
'gancao',
$now
);
$assertSame('RECONCILE', $expiredGancaoDecision['action'], 'an expired Gancao lease must enter reconciliation');
$assertSame(true, $expiredGancaoDecision['lease_expired'], 'Gancao lease recovery must fence the stale worker');
$manualSuccess = PharmacySubmissionReconciliationPolicy::resolve(
['target' => 'gancao', 'status' => 'PENDING_RECONCILE'],
'CONFIRM_SUCCESS',
'GC-MANUAL-1',
'已在甘草后台核对订单'
);
$assertSame('SUCCESS', $manualSuccess['status'], 'manual remote-success confirmation must finalize the claim');
$assertSame('GC-MANUAL-1', $manualSuccess['remote_order_no'], 'manual success must persist the verified Gancao order number');
$manualNotCreated = PharmacySubmissionReconciliationPolicy::resolve(
['target' => 'gancao', 'status' => 'UNKNOWN'],
'CONFIRM_NOT_CREATED',
'',
'甘草后台确认未生成订单'
);
$assertSame('FAILED', $manualNotCreated['status'], 'manual no-create confirmation must release the claim for an intentional retry');
$expiredPendingGancaoResolution = PharmacySubmissionReconciliationPolicy::resolve(
['target' => 'gancao', 'status' => 'PENDING', 'lease_expires_at' => $now - 1],
'CONFIRM_NOT_CREATED',
'',
'甘草后台确认未生成订单',
$now
);
$assertSame(
'FAILED',
$expiredPendingGancaoResolution['status'],
'an expired Gancao PENDING lease must be recoverable through explicit operator confirmation'
);
foreach (
[
[['target' => 'direct', 'status' => 'PENDING_RECONCILE'], 'CONFIRM_NOT_CREATED', '', 'checked'],
[['target' => 'gancao', 'status' => 'SUCCESS'], 'CONFIRM_NOT_CREATED', '', 'checked'],
[['target' => 'gancao', 'status' => 'UNKNOWN'], 'CONFIRM_SUCCESS', '', 'checked'],
[['target' => 'gancao', 'status' => 'UNKNOWN'], 'CONFIRM_NOT_CREATED', '', ''],
[['target' => 'gancao', 'status' => 'PENDING', 'lease_expires_at' => $now + 30], 'CONFIRM_NOT_CREATED', '', 'checked'],
] as [$claimToResolve, $resolution, $remoteOrderNo, $note]
) {
try {
PharmacySubmissionReconciliationPolicy::resolve($claimToResolve, $resolution, $remoteOrderNo, $note);
throw new RuntimeException('invalid manual reconciliation unexpectedly succeeded');
} catch (DomainException) {
$assertTrue(true, 'manual reconciliation must reject invalid target/status/evidence');
}
}
$assertSame(
'direct',
PharmacySupplyMode::resolve(['ship_mode' => 'direct', 'ej_pharmacy_order_no' => '']),
'direct orders must remain visible as Luoyang direct before remote upload'
);
$assertSame(
'direct',
PharmacySupplyMode::resolve(['ship_mode' => 'direct', 'ej_pharmacy_order_no' => 'EJ-1']),
'uploaded Luoyang orders must resolve as direct'
);
$assertSame(
'gancao',
PharmacySupplyMode::resolve(['ship_mode' => 'gancao', 'gancao_reciperl_order_no' => 'GC-1']),
'orders with a Gancao remote number must resolve as Gancao'
);
$assertSame(
'self',
PharmacySupplyMode::resolve(['ship_mode' => 'gancao', 'gancao_reciperl_order_no' => '']),
'legacy non-uploaded non-direct orders must retain the self-operated bucket'
);
$assertSame('洛阳直发', PharmacySupplyMode::label('direct'), 'direct export and UI label must be explicit');
try {
PharmacySubmissionClaimPolicy::existingDecision(
['target' => 'direct', 'status' => 'PENDING_RECONCILE'],
'gancao'
);
throw new RuntimeException('uncertain claim unexpectedly switched pharmacy target');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '对账'), 'uncertain claim must block a competing pharmacy target');
}
$mutationEvents = [];
$mutationResult = LockedPharmacySnapshotMutation::run(
static function () use (&$mutationEvents): array {
$mutationEvents[] = 'order';
return ['id' => 10];
},
static function () use (&$mutationEvents): ?array {
$mutationEvents[] = 'claim';
return ['status' => 'FAILED'];
},
static function () use (&$mutationEvents): string {
$mutationEvents[] = 'save';
return 'saved';
}
);
$assertSame(['order', 'claim', 'save'], $mutationEvents, 'snapshot mutation must lock order then claim before saving');
$assertSame('saved', $mutationResult, 'mutable snapshot must execute its mutation inside the lock scope');
$blockedMutationRan = false;
try {
LockedPharmacySnapshotMutation::run(
static fn (): array => ['id' => 10],
static fn (): array => ['status' => 'PENDING_RECONCILE'],
static function () use (&$blockedMutationRan): void { $blockedMutationRan = true; }
);
throw new RuntimeException('locked snapshot mutation unexpectedly ran');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '不可修改'), 'locked mutation must return the snapshot policy error');
}
$assertSame(false, $blockedMutationRan, 'locked snapshot must be rejected before its save callback');
try {
LockedPharmacySnapshotMutation::run(
static fn (): array => ['id' => 10],
static fn (): ?array => null,
static fn (): bool => false
);
throw new RuntimeException('false mutation result unexpectedly committed');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '未完成'), 'false mutation result must abort its transaction');
}
$identityRows = [
['id' => 101, 'name' => '黄芪', 'status' => 1, 'delete_time' => null],
['id' => 102, 'name' => '甘草', 'status' => 1, 'delete_time' => null],
['id' => 103, 'name' => '甘草', 'status' => 1, 'delete_time' => null],
];
$loadIdentityIds = static function (array $ids) use ($identityRows): array {
return array_values(array_filter(
$identityRows,
static fn (array $row): bool => in_array((int) $row['id'], $ids, true)
));
};
$loadIdentityNames = static function (array $names) use ($identityRows): array {
return array_values(array_filter(
$identityRows,
static fn (array $row): bool => in_array((string) $row['name'], $names, true)
));
};
$normalizedIdentity = PharmacyHerbIdentityResolver::resolve([
['medicine_id' => 101, 'name' => '旧黄芪名', 'dosage' => 10],
], $loadIdentityIds, $loadIdentityNames);
$assertSame(101, $normalizedIdentity[0]['medicine_id'], 'explicit local medicine id must be preserved');
$assertSame('黄芪', $normalizedIdentity[0]['name'], 'explicit local medicine id must refresh the snapshotted name');
$legacyIdentity = PharmacyHerbIdentityResolver::resolve([
['name' => '黄芪', 'dosage' => 10],
], $loadIdentityIds, $loadIdentityNames);
$assertSame(101, $legacyIdentity[0]['medicine_id'], 'legacy name-only herb must resolve when exactly one local medicine matches');
try {
PharmacyHerbIdentityResolver::resolve([
['name' => '甘草', 'dosage' => 6],
], $loadIdentityIds, $loadIdentityNames);
throw new RuntimeException('duplicate medicine name unexpectedly resolved to one id');
} catch (DomainException $exception) {
$assertTrue(
str_contains($exception->getMessage(), '甘草') && str_contains($exception->getMessage(), '多个同名'),
'duplicate local medicine names must block upload/save with an actionable herb error'
);
}
try {
PharmacyHerbIdentityResolver::resolve([
['name' => '未收录药材', 'dosage' => 3],
], $loadIdentityIds, $loadIdentityNames);
throw new RuntimeException('missing medicine name unexpectedly resolved');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '未收录药材'), 'missing local medicine must name the blocked herb');
}
$assertSame(
false,
PharmacyRemoteSnapshotPolicy::isLocked(
['gancao_reciperl_order_no' => '', 'ej_pharmacy_order_no' => null],
['status' => 'FAILED']
),
'failed claim without a remote order must leave the snapshot editable for retry or mode change'
);
$assertSame(
true,
PharmacyRemoteSnapshotPolicy::isLocked(
['gancao_reciperl_order_no' => '', 'ej_pharmacy_order_no' => null],
['status' => 'PENDING']
),
'pending pharmacy claim must lock the remote snapshot'
);
$assertSame(
true,
PharmacyRemoteSnapshotPolicy::isLocked(
['gancao_reciperl_order_no' => '', 'ej_pharmacy_order_no' => null],
['status' => 'PENDING_RECONCILE']
),
'uncertain pharmacy result must keep every remote snapshot mutation locked'
);
$assertSame(
true,
PharmacyRemoteSnapshotPolicy::isLocked(
['gancao_reciperl_order_no' => '', 'ej_pharmacy_order_no' => 'EJ-LOCKED'],
null
),
'any remote pharmacy order number must lock the remote snapshot'
);
try {
PharmacyRemoteSnapshotPolicy::assertMutable(
['gancao_reciperl_order_no' => 'GC-LOCKED', 'ej_pharmacy_order_no' => null],
['status' => 'SUCCESS']
);
throw new RuntimeException('remote snapshot unexpectedly remained mutable');
} catch (DomainException $exception) {
$assertTrue(str_contains($exception->getMessage(), '取消后创建新版本'), 'locked snapshot must explain the revision workflow');
}
$callbackPayload = ['event_id' => 'event-1', 'source_order_no' => 'PO-1', 'pharmacy_order_no' => 'EJ-1'];
$earlyFailed = '';
$earlyWorkflow = new EjPharmacyCallbackWorkflow(
static fn (): ?array => ['id' => 1, 'process_status' => 'PENDING'],
static fn (): array => [],
static fn (): ?array => null,
static function (): array { throw new EjPharmacyCallbackRetryException('远程单号尚未回写'); },
static function (array $inbox, string $error) use (&$earlyFailed): void { $earlyFailed = $error; },
static fn (): bool => false
);
$earlyResult = $earlyWorkflow->handle($callbackPayload);
$assertSame(503, $earlyResult['http_status'], 'early callback must ask ej ERP to retry after local upload writeback');
$assertTrue(str_contains($earlyFailed, '尚未回写'), 'early callback retry reason must be retained on inbox');
$failedRetryProcessed = 0;
$failedRetryWorkflow = new EjPharmacyCallbackWorkflow(
static fn (): ?array => ['id' => 2, 'process_status' => 'FAILED'],
static fn (): array => [],
static fn (): ?array => null,
static function () use (&$failedRetryProcessed): array {
++$failedRetryProcessed;
return ['process_status' => 'PROCESSED'];
},
static function (): void {},
static fn (): bool => false
);
$failedRetryResult = $failedRetryWorkflow->handle($callbackPayload);
$assertSame(200, $failedRetryResult['http_status'], 'failed inbox row must be eligible for reprocessing');
$assertSame(1, $failedRetryProcessed, 'failed inbox retry must execute processing exactly once');
$runtimeFailed = '';
$runtimeWorkflow = new EjPharmacyCallbackWorkflow(
static fn (): ?array => ['id' => 3, 'process_status' => 'PENDING'],
static fn (): array => [],
static fn (): ?array => null,
static function (): array { throw new RuntimeException('database unavailable'); },
static function (array $inbox, string $error) use (&$runtimeFailed): void { $runtimeFailed = $error; },
static fn (): bool => false
);
$runtimeResult = $runtimeWorkflow->handle($callbackPayload);
$assertSame(500, $runtimeResult['http_status'], 'unknown callback processing failure must return 5xx');
$assertSame('database unavailable', $runtimeFailed, 'runtime callback failure must remain retryable in inbox');
$duplicateReloaded = 0;
$duplicateWorkflow = new EjPharmacyCallbackWorkflow(
static fn (): ?array => null,
static function (): array { throw new RuntimeException('duplicate event id'); },
static function () use (&$duplicateReloaded): ?array {
++$duplicateReloaded;
return ['id' => 4, 'process_status' => 'PENDING'];
},
static fn (): array => ['process_status' => 'PROCESSED'],
static function (): void {},
static fn (Throwable $exception): bool => str_contains($exception->getMessage(), 'duplicate')
);
$duplicateResult = $duplicateWorkflow->handle($callbackPayload);
$assertSame(200, $duplicateResult['http_status'], 'concurrent duplicate inbox insert must reload and process instead of returning 422');
$assertSame(1, $duplicateReloaded, 'duplicate inbox race must reload the winning row exactly once');
$processedCalls = 0;
$processedWorkflow = new EjPharmacyCallbackWorkflow(
static fn (): ?array => ['id' => 5, 'process_status' => 'PROCESSED'],
static fn (): array => [],
static fn (): ?array => null,
static function () use (&$processedCalls): array { ++$processedCalls; return []; },
static function (): void {},
static fn (): bool => false
);
$processedResult = $processedWorkflow->handle($callbackPayload);
$assertSame(true, $processedResult['duplicate'], 'only processed inbox rows may return immediate idempotent success');
$assertSame(0, $processedCalls, 'processed inbox duplicate must not execute business processing again');
$assertSame(
'SF',
PharmacyLogisticsValue::normalize("\u{200B}\u{00A0}SF\u{3000}\u{FEFF}", 32, '快递公司'),
'callback logistics values must trim Unicode and zero-width edge whitespace'
);
try {
PharmacyLogisticsValue::normalize(str_repeat('运', 101), 100, '运单号');
throw new RuntimeException('overlong Unicode tracking number unexpectedly accepted');
} catch (InvalidArgumentException $exception) {
$assertTrue(str_contains($exception->getMessage(), '100'), 'callback logistics length error must expose the storage limit');
}
$legacyUploadUri = 'tcm.prescriptionorder/submitgancaorecipel';
$unifiedUploadUri = 'tcm.prescriptionorder/uploadtopharmacy';
$reconcileUri = 'tcm.prescriptionorder/confirmgancaosubmission';
$assertSame(
$unifiedUploadUri,
PharmacyUploadPermissionAlias::canonicalUri($legacyUploadUri),
'legacy Gancao-named URI must canonicalize to the unified pharmacy upload capability'
);
$assertSame(true, PharmacyUploadPermissionAlias::isControlled($legacyUploadUri), 'legacy upload alias must always remain permission controlled');
$assertSame(
true,
PharmacyUploadPermissionAlias::allows($unifiedUploadUri, [$legacyUploadUri]),
'role holding the legacy upload permission must be allowed to call the unified URI'
);
$assertSame(
true,
PharmacyUploadPermissionAlias::allows($legacyUploadUri, [$unifiedUploadUri]),
'role holding the unified upload permission must be allowed to call the legacy alias'
);
$assertSame($reconcileUri, PharmacyUploadPermissionAlias::canonicalUri($reconcileUri), 'manual reconciliation must retain an independent permission identity');
$assertSame(true, PharmacyUploadPermissionAlias::isControlled($reconcileUri), 'manual reconciliation must remain permission controlled');
$assertSame(false, PharmacyUploadPermissionAlias::allows($reconcileUri, [$unifiedUploadUri]), 'upload permission must not authorize manual reconciliation');
$assertSame(true, PharmacyUploadPermissionAlias::allows($reconcileUri, [$reconcileUri]), 'the dedicated reconciliation permission must authorize its own endpoint');
$payload = EjPharmacyPayload::build([
'order_no' => 'PO001',
'recipient_name' => 'Patient A',
'recipient_phone' => '13800000000',
'shipping_province' => '河南省',
'shipping_city' => '洛阳市',
'shipping_district' => '洛龙区',
'shipping_address' => '测试路1号',
'dose_count' => 7,
], [
'id' => 11,
'patient_name' => 'Patient A',
'id_card' => '410000000000000000',
'phone' => '13800000000',
'clinical_diagnosis' => '测试诊断',
'processing_type' => 'decoction',
'creator_id' => 9,
'doctor_name' => 'Doctor A',
'usage_instruction' => '饭后温服',
'doctor_signature' => ['url' => 'https://example.test/sign.png', 'signed_at' => '2026-07-21T10:00:00+08:00'],
'herbs' => [
['medicine_id' => 101, 'name' => 'Herb A', 'dose' => '10', 'unit' => '克'],
],
], [101 => 'EJ000001'], 1);
$assertSame('zyt', $payload['source_system'], 'source system must be zyt');
$assertSame('PO001', $payload['source_order_no'], 'source order number must be preserved');
$assertSame('EJ000001', $payload['prescription']['medicines'][0]['medicine_code'], 'local medicine must map to ej code');
$assertSame('10.0000', $payload['prescription']['medicines'][0]['quantity'], 'medicine quantity must use four decimals');
$assertSame('测试诊断', $payload['prescription']['diagnosis'], 'ERP diagnosis must use the production prescription field');
$assertSame('9', $payload['prescription']['doctor']['source_doctor_id'], 'ERP doctor id must use the prescription creator');
$assertSame('饭后温服', $payload['prescription']['instructions'], 'ERP instructions must use the production prescription field');
$missingRejected = false;
try {
EjPharmacyPayload::build(['order_no' => 'PO002'], [
'patient_name' => 'A',
'phone' => '13800000000',
'herbs' => [['medicine_id' => 999, 'name' => 'Unknown', 'dose' => 1, 'unit' => '克']],
], [], 1);
} catch (DomainException $exception) {
$missingRejected = str_contains($exception->getMessage(), 'Unknown');
}
$assertTrue($missingRejected, 'unmapped medicine must block ERP upload');
$migrationPath = dirname(__DIR__, 2) . '/sql/1.9.20260721/luoyang_pharmacy_erp.sql';
$migrationMatches = glob(dirname(__DIR__, 2) . '/sql/*/luoyang_pharmacy_erp.sql') ?: [];
$legacyMigrationMatches = glob(dirname(__DIR__, 2) . '/database/migrations/*luoyang_pharmacy_erp.sql') ?: [];
sort($migrationMatches);
$assertSame([$migrationPath], $migrationMatches, 'zyt ERP projection must have exactly one versioned SQL migration');
$assertSame([], $legacyMigrationMatches, 'zyt ERP projection must not use database/migrations');
$assertTrue(is_file($migrationPath), 'zyt ERP projection migration must exist');
$migration = is_file($migrationPath) ? (string) file_get_contents($migrationPath) : '';
foreach (['zyt_ej_medicine_catalog', 'zyt_ej_medicine_mapping', 'zyt_ej_pharmacy_submission', 'zyt_ej_pharmacy_callback_inbox'] as $table) {
$assertTrue(str_contains($migration, "`{$table}`"), "migration must create {$table}");
}
$assertTrue(str_contains($migration, '`zyt_pharmacy_submission_claim`'), 'migration must create a cross-pharmacy durable claim table');
$assertTrue(
str_contains($migration, '`status` varchar(24) NOT NULL DEFAULT \'PENDING\''),
'claim status column must hold PENDING_RECONCILE without truncation'
);
$assertTrue(
(bool) preg_match(
'/UNIQUE KEY `uk_order_revision` \(`prescription_order_id`,`source_revision`\)/',
substr($migration, (int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_pharmacy_submission_claim`'))
),
'cross-pharmacy claim must allow only one target per order revision'
);
foreach (['`target`', '`status`', '`claim_token`', '`idempotency_key`', '`claimed_at`', '`completed_at`', '`failed_at`'] as $claimColumn) {
$assertTrue(str_contains($migration, $claimColumn), "cross-pharmacy claim must persist {$claimColumn}");
}
$assertTrue(
str_contains($migration, 'MODIFY COLUMN `express_company` varchar(32)')
&& str_contains($migration, 'MODIFY COLUMN `tracking_number` varchar(100)'),
'order logistics projection must match ej callback length limits'
);
$assertTrue(
str_contains($migration, 'ALTER TABLE `zyt_express_tracking`')
&& str_contains($migration, 'ALTER TABLE `zyt_express_trace`'),
'real tracking and trace tables must be widened for ej tracking numbers'
);
$assertTrue(str_contains($migration, '`ej_pharmacy_order_no`'), 'business order must store ej pharmacy order number');
$orderProjectionSql = substr(
$migration,
0,
(int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_catalog`')
);
$assertTrue(
(bool) preg_match('/`ej_pharmacy_order_no`\s+varchar\(40\)\s+NULL\s+DEFAULT\s+NULL/i', $orderProjectionSql),
'remote order number must be nullable so unique index permits multiple pending orders'
);
$assertTrue(
!(bool) preg_match('/`ej_pharmacy_order_no`\s+varchar\(40\)\s+NOT NULL\s+DEFAULT\s+[\'\"]{2}/i', $orderProjectionSql),
'remote order number must not retain the duplicate empty-string default'
);
$assertTrue(
str_contains($orderProjectionSql, "UPDATE `zyt_tcm_prescription_order` SET `ej_pharmacy_order_no` = NULL"),
'empty remote order numbers must be normalized before unique index creation'
);
$assertTrue(
substr_count($orderProjectionSql, 'information_schema.COLUMNS') >= 7,
'every existing-order column addition must be guarded for repeat execution'
);
$assertTrue(
substr_count($orderProjectionSql, 'information_schema.STATISTICS') >= 2,
'existing-order indexes must be guarded for repeat execution'
);
$callbackController = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/api/controller/EjPharmacyCallbackController.php'
);
$prescriptionOrderLogicSource = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/adminapi/logic/tcm/PrescriptionOrderLogic.php'
);
$prescriptionOrderListsSource = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/adminapi/lists/tcm/PrescriptionOrderLists.php'
);
$prescriptionLogicSource = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/adminapi/logic/tcm/PrescriptionLogic.php'
);
$claimServiceSource = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/common/service/pharmacy/PharmacySubmissionClaimService.php'
);
$ejPharmacyClientSource = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjPharmacyClient.php'
);
$callbackLockPattern = <<<'REGEX'
/PrescriptionOrder::where\('ej_pharmacy_order_no', \$pharmacyOrderNo\).*?->whereNull\('delete_time'\).*?->lock\(true\).*?->find\(\)/s
REGEX;
$assertTrue(
(bool) preg_match($callbackLockPattern, $callbackController),
'callback must lock the order row before comparing and advancing status_version'
);
$assertTrue(
substr_count($prescriptionOrderLogicSource, 'assertRemoteSnapshotMutable($order)') >= 4,
'order edit, ship-mode, patient and usage mutations must share the remote snapshot lock helper'
);
$assertTrue(
str_contains($prescriptionOrderLogicSource, 'PharmacySubmissionClaimService::markReconcile('),
'unified upload must wire uncertain outcomes to the durable reconciliation transition'
);
$assertTrue(
str_contains($prescriptionOrderLogicSource, "['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS']")
&& substr_count($prescriptionOrderLogicSource, 'pharmacyClaimBlocksUpload($item,') >= 2,
'upload eligibility must hide every active or uncertain claim and leave Gancao reconciliation as the only action'
);
$claimDetailStart = strpos($prescriptionOrderLogicSource, '$claim = PharmacySubmissionClaimService::claimForOrder($id);');
$claimDetailEnd = strpos($prescriptionOrderLogicSource, 'return $arr;', $claimDetailStart);
$claimDetailSource = substr($prescriptionOrderLogicSource, $claimDetailStart, $claimDetailEnd - $claimDetailStart);
$assertTrue(
str_contains($claimDetailSource, "\$arr['can_upload_pharmacy'] = self::canUploadToPharmacy("),
'order detail must expose claim-aware pharmacy upload eligibility just like the list response'
);
$assertTrue(
str_contains($prescriptionOrderLogicSource, 'private static function pharmacyClaimBlocksUpload(')
&& str_contains($prescriptionOrderLogicSource, "\$mode === 'direct'")
&& str_contains($prescriptionOrderLogicSource, "\$leaseExpiresAt <= time()"),
'expired direct leases must be reclaimable from the normal upload action while active leases remain fenced'
);
$assertTrue(
str_contains($prescriptionOrderLogicSource, "!empty(\$claim['reconcile'])")
&& str_contains($prescriptionOrderLogicSource, '->prescriptionOrder('),
'same-target reconciliation must query the remote order before retrying creation'
);
$prescriptionOrderQuerySource = substr(
$ejPharmacyClientSource,
(int) strpos($ejPharmacyClientSource, 'public function prescriptionOrder('),
(int) strpos($ejPharmacyClientSource, 'private function request(', (int) strpos($ejPharmacyClientSource, 'public function prescriptionOrder('))
- (int) strpos($ejPharmacyClientSource, 'public function prescriptionOrder(')
);
$assertTrue(
str_contains($prescriptionOrderQuerySource, "'source_system' => 'zyt'"),
'EJ reconciliation lookup must include the source_system required by the remote order identity contract'
);
$assertTrue(
str_contains($prescriptionOrderLogicSource, 'private static function canonicalPharmacyOrder('),
'remote payload must reload the canonical order after acquiring the durable claim'
);
$assertTrue(
substr_count($prescriptionOrderLogicSource, 'LockedPharmacySnapshotMutation::execute(') >= 7,
'order edit, patient, ship, withdraw, audit revoke, ship-mode and usage mutations must run inside locked snapshot transactions'
);
$assertTrue(
substr_count($prescriptionLogicSource, 'LockedPharmacySnapshotMutation::executeForPrescription(') >= 4,
'prescription edit, patient patch, delete and void must lock every linked order and claim before saving'
);
foreach (['patchPrescriptionPatient', 'edit', 'ship', 'withdraw', 'revokeRxAudit', 'setShipMode', 'patchPrescriptionUsage'] as $method) {
$start = (int) strpos($prescriptionOrderLogicSource, 'public static function ' . $method . '(');
$end = (int) strpos($prescriptionOrderLogicSource, 'private static function ' . $method . 'Locked(', $start);
$wrapperSource = substr($prescriptionOrderLogicSource, $start, $end - $start);
$assertTrue(
str_contains($wrapperSource, "self::setError('');"),
"protected order mutation {$method} must clear stale errors before acquiring snapshot locks"
);
$assertTrue(
str_contains($wrapperSource, "if (self::getError() === '') {")
&& str_contains($wrapperSource, 'self::setError($exception->getMessage());'),
"protected order mutation {$method} must preserve precise inner business errors on rollback"
);
}
foreach (['edit', 'patchPatientContact', 'delete', 'void'] as $method) {
$start = (int) strpos($prescriptionLogicSource, 'public static function ' . $method . '(');
$end = (int) strpos($prescriptionLogicSource, 'private static function ' . $method . 'Locked(', $start);
$wrapperSource = substr($prescriptionLogicSource, $start, $end - $start);
$assertTrue(
str_contains($wrapperSource, "self::setError('');"),
"protected prescription mutation {$method} must clear stale errors before acquiring snapshot locks"
);
$assertTrue(
str_contains($wrapperSource, "if (self::getError() === '') {")
&& str_contains($wrapperSource, 'self::setError($exception->getMessage());'),
"protected prescription mutation {$method} must preserve precise inner business errors on rollback"
);
}
$shipLockedSource = substr(
$prescriptionOrderLogicSource,
(int) strpos($prescriptionOrderLogicSource, 'private static function shipLocked('),
(int) strpos($prescriptionOrderLogicSource, 'public static function withdraw(')
- (int) strpos($prescriptionOrderLogicSource, 'private static function shipLocked(')
);
$assertTrue(
!str_contains($shipLockedSource, '->ship_mode ='),
'shipping logistics must never overwrite the pharmacy target snapshot'
);
$assertTrue(
substr_count($prescriptionLogicSource, 'remoteSnapshotLockErrorForPrescription') >= 2,
'consumer prescription edit and patient patch must enforce the linked order snapshot lock'
);
$assertTrue(
str_contains($claimServiceSource, 'public static function markReconcile('),
'claim persistence must expose an explicit reconciliation transition'
);
$assertTrue(
substr_count($claimServiceSource, "Db::name('tcm_prescription_order')") >= 4,
'acquire and every claim transition must lock the order before the claim'
);
$assertTrue(
str_contains($prescriptionOrderLogicSource, 'isConfirmedEjNoCreateHttpStatus('),
'direct EJ submission must use the explicit no-create HTTP classifier before releasing a claim'
);
$prescriptionSnapshotCheckSource = substr(
$prescriptionOrderLogicSource,
(int) strpos($prescriptionOrderLogicSource, 'public static function remoteSnapshotLockErrorForPrescription('),
(int) strpos($prescriptionOrderLogicSource, 'private static function buildDeptPath(', (int) strpos($prescriptionOrderLogicSource, 'public static function remoteSnapshotLockErrorForPrescription('))
- (int) strpos($prescriptionOrderLogicSource, 'public static function remoteSnapshotLockErrorForPrescription(')
);
$lockedMutationSource = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/common/service/pharmacy/LockedPharmacySnapshotMutation.php'
);
$assertTrue(
!str_contains($prescriptionSnapshotCheckSource, "where('fulfillment_status', '<>', 4)"),
'canceled linked orders must still block prescription mutations when remote state remains'
);
$assertTrue(
!str_contains($lockedMutationSource, "where('fulfillment_status', '<>', 4)"),
'transactional prescription snapshot locking must include canceled linked orders'
);
$gancaoSubmitSource = substr(
$prescriptionOrderLogicSource,
(int) strpos($prescriptionOrderLogicSource, 'private static function submitGancaoRemote('),
(int) strpos($prescriptionOrderLogicSource, 'public static function previewGancaoRecipel(')
- (int) strpos($prescriptionOrderLogicSource, 'private static function submitGancaoRemote(')
);
$assertTrue(
substr_count($gancaoSubmitSource, 'throw new PharmacyReconciliationRequiredException(') >= 2,
'Gancao transport failure and success-without-order-number must remain pending reconciliation'
);
$assertTrue(
!str_contains($prescriptionOrderLogicSource, "str_contains(\$error, '下单通信失败')"),
'Gancao uncertainty classification must use explicit exception types instead of localized error text'
);
$mappingLists = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/adminapi/lists/pharmacy/MedicineMappingLists.php'
);
$assertTrue(
str_contains(
$mappingLists,
"'m.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'"
),
'mapping list must exclude inactive and soft-deleted mappings from remote display fields'
);
$validLocal = ['id' => 101, 'status' => 1, 'delete_time' => null];
$validRemote = [
'medicine_code' => 'EJ000001',
'status' => 1,
'remote_deleted' => 0,
];
EjMedicineMappingPolicy::assertValid($validLocal, $validRemote);
$unlinkActive = EjMedicineMappingPolicy::unlinkDecision(
['id' => 101, 'status' => 0, 'delete_time' => null],
['id' => 9, 'local_medicine_id' => 101, 'status' => 1, 'delete_time' => null]
);
$assertSame(9, $unlinkActive['mapping_id'], 'unlink must target the locked mapping row owned by the local medicine');
$assertSame(false, $unlinkActive['already_unlinked'], 'active mapping must be disabled');
$unlinkAgain = EjMedicineMappingPolicy::unlinkDecision(
['id' => 101, 'status' => 0, 'delete_time' => null],
['id' => 9, 'local_medicine_id' => 101, 'status' => 0, 'delete_time' => time()]
);
$assertSame(true, $unlinkAgain['already_unlinked'], 'concurrent repeated unlink must be idempotent');
try {
EjMedicineMappingPolicy::unlinkDecision(
['id' => 101, 'status' => 1, 'delete_time' => null],
['id' => 9, 'local_medicine_id' => 202, 'status' => 1, 'delete_time' => null]
);
throw new RuntimeException('foreign mapping ownership unexpectedly accepted');
} catch (DomainException $exception) {
$assertSame('药材映射归属不匹配', $exception->getMessage(), 'unlink must not disable another local medicine mapping');
}
foreach ([
[['id' => 101, 'status' => 0, 'delete_time' => null], $validRemote, '本地药材已停用,不能建立映射'],
[$validLocal, ['medicine_code' => 'EJ000001', 'status' => 0, 'remote_deleted' => 0], '洛阳药房药材已停用,不能建立映射'],
[$validLocal, ['medicine_code' => 'EJ000001', 'status' => 1, 'remote_deleted' => 1], '洛阳药房药材已删除,不能建立映射'],
] as [$local, $remote, $expectedMessage]) {
try {
EjMedicineMappingPolicy::assertValid($local, $remote);
throw new RuntimeException('invalid mapping unexpectedly accepted');
} catch (DomainException $exception) {
$assertSame($expectedMessage, $exception->getMessage(), 'mapping policy must reject invalid endpoint');
}
}
$createdMerge = EjMedicineCatalogSyncPolicy::merge(null, [
'medicine_code' => ' EJ000002 ',
'name' => '黄芪',
'brand' => '测试品牌',
'unit' => '克',
'settlement_price' => '1.2000',
'retail_price' => '1.8000',
'status' => 1,
'catalog_version' => 10,
'deleted' => false,
]);
$assertSame('created', $createdMerge['action'], 'new catalog item must be classified as created');
$assertSame('EJ000002', $createdMerge['values']['medicine_code'], 'remote medicine code must be trimmed');
$assertSame(0, $createdMerge['deactivated'], 'active catalog item is not deactivated');
$deactivatedMerge = EjMedicineCatalogSyncPolicy::merge([
'medicine_code' => 'EJ000002',
'name' => '黄芪',
'brand' => '测试品牌',
'unit' => '克',
'settlement_price' => '1.2000',
'retail_price' => '1.8000',
'status' => 1,
'catalog_version' => 10,
'remote_deleted' => 0,
], [
'medicine_code' => 'EJ000002',
'name' => '黄芪',
'brand' => '测试品牌',
'unit' => '克',
'settlement_price' => '1.2000',
'retail_price' => '1.8000',
'status' => 1,
'catalog_version' => 11,
'deleted' => true,
]);
$assertSame('updated', $deactivatedMerge['action'], 'remote deletion must update catalog item');
$assertSame(1, $deactivatedMerge['deactivated'], 'active-to-inactive transition must be counted');
$assertSame(0, $deactivatedMerge['values']['status'], 'remote deletion must disable local catalog item');
$fetchCursors = [];
$mergedPages = [];
$released = 0;
$successState = [];
$workflow = new EjMedicineCatalogSyncWorkflow(
static fn (): bool => true,
static function () use (&$released): void { ++$released; },
static fn (): int => 0,
static function (int $cursor, int $limit) use (&$fetchCursors): array {
$fetchCursors[] = $cursor;
if ($cursor === 0) {
return ['http_status' => 200, 'body' => ['code' => 0, 'data' => [
'items' => [['medicine_code' => 'EJ1']], 'next_cursor' => 10, 'has_more' => true,
]]];
}
return ['http_status' => 200, 'body' => ['code' => 0, 'data' => [
'items' => [['medicine_code' => 'EJ2']], 'next_cursor' => 12, 'has_more' => false,
]]];
},
static function (array $items, int $cursor) use (&$mergedPages): array {
$mergedPages[] = [$cursor, $items];
return ['created' => 1, 'updated' => 0, 'deactivated' => 0];
},
static function (int $cursor, array $stats) use (&$successState): void {
$successState = [$cursor, $stats];
},
static function (): void {}
);
$syncStats = $workflow->sync(200);
$assertSame([0, 10], $fetchCursors, 'empty cursor sync must fetch every page in order');
$assertSame([10, 12], array_column($mergedPages, 0), 'each page merge must persist its next cursor');
$assertSame(2, $syncStats['created'], 'page merge stats must aggregate');
$assertSame(2, $syncStats['pulled'], 'sync result must expose pulled count');
$assertSame(2, $syncStats['received'], 'received remains a compatibility alias for command callers');
$assertSame(12, $syncStats['cursor'], 'workflow must return final cursor');
$assertSame(1, $released, 'successful sync must release lock exactly once');
$assertSame(12, $successState[0], 'successful sync must persist final cursor');
$failureCursor = null;
$releasedAfterFailure = 0;
$failedWorkflow = new EjMedicineCatalogSyncWorkflow(
static fn (): bool => true,
static function () use (&$releasedAfterFailure): void { ++$releasedAfterFailure; },
static fn (): int => 0,
static function (int $cursor): array {
if ($cursor === 0) {
return ['http_status' => 200, 'body' => ['code' => 0, 'data' => [
'items' => [['medicine_code' => 'EJ1']], 'next_cursor' => 10, 'has_more' => true,
]]];
}
return ['http_status' => 503, 'body' => ['code' => 500, 'message' => 'temporary remote failure']];
},
static fn (): array => ['created' => 1, 'updated' => 0, 'deactivated' => 0],
static function (): void {},
static function (int $cursor) use (&$failureCursor): void { $failureCursor = $cursor; }
);
try {
$failedWorkflow->sync(200);
throw new RuntimeException('remote failure unexpectedly accepted');
} catch (RuntimeException $exception) {
$assertSame('temporary remote failure', $exception->getMessage(), 'remote business error must remain actionable');
}
$assertSame(10, $failureCursor, 'failed sync must retain last atomically merged cursor');
$assertSame(1, $releasedAfterFailure, 'failed sync must release lock exactly once');
$releasedAfterCursorFailure = 0;
$cursorFailureRecorded = 0;
$cursorFailedWorkflow = new EjMedicineCatalogSyncWorkflow(
static fn (): bool => true,
static function () use (&$releasedAfterCursorFailure): void { ++$releasedAfterCursorFailure; },
static function (): int { throw new RuntimeException('cursor storage unavailable'); },
static fn (): array => [],
static fn (): array => [],
static function (): void {},
static function () use (&$cursorFailureRecorded): void { ++$cursorFailureRecorded; }
);
try {
$cursorFailedWorkflow->sync(200);
throw new RuntimeException('cursor load failure unexpectedly accepted');
} catch (RuntimeException $exception) {
$assertSame('cursor storage unavailable', $exception->getMessage(), 'cursor load error must propagate');
}
$assertSame(1, $releasedAfterCursorFailure, 'cursor load failure after lease acquisition must release lease');
$assertSame(1, $cursorFailureRecorded, 'cursor load failure must record a failed synchronization');
$releaseWithoutLock = 0;
$lockedWorkflow = new EjMedicineCatalogSyncWorkflow(
static fn (): bool => false,
static function () use (&$releaseWithoutLock): void { ++$releaseWithoutLock; },
static fn (): int => 0,
static fn (): array => [],
static fn (): array => [],
static function (): void {},
static function (): void {}
);
try {
$lockedWorkflow->sync(200);
throw new RuntimeException('concurrent sync unexpectedly accepted');
} catch (DomainException $exception) {
$assertSame('洛阳药房目录正在同步,请稍后重试', $exception->getMessage(), 'concurrent sync must return a business error');
}
$assertSame(0, $releaseWithoutLock, 'workflow must not release a lock it did not acquire');
$importPayload = [
'source_system' => 'zyt',
'import_id' => 'bootstrap-test-1',
'items' => [[
'source_medicine_id' => '1',
'name' => '黄芪',
'brand' => '',
'unit' => '克',
'settlement_price' => '1.0000',
'retail_price' => '2.0000',
'status' => 1,
]],
];
foreach ([403, 409, 422] as $structuredStatus) {
$capturedImportRequest = [];
$client = new EjPharmacyClient(
'https://ej.example.test',
'app-key',
'app-secret',
static function (string $method, string $path, string $body, array $headers) use (
$structuredStatus,
&$capturedImportRequest
): array {
$capturedImportRequest = [$method, $path, json_decode($body, true), $headers];
return [
'http_status' => $structuredStatus,
'body' => ['code' => $structuredStatus, 'message' => 'structured error', 'data' => ['field' => 'items']],
'request_id' => 'request-structured',
];
}
);
$structuredResponse = $client->importMedicines($importPayload);
$assertSame($structuredStatus, $structuredResponse['http_status'], 'medicine import must preserve structured HTTP status');
$assertSame(
['code' => $structuredStatus, 'message' => 'structured error', 'data' => ['field' => 'items']],
$structuredResponse['body'],
'medicine import must preserve structured EJ error bodies'
);
$assertSame('POST', $capturedImportRequest[0], 'medicine import must use POST');
$assertSame('/api/openapi/v1/medicine-imports', $capturedImportRequest[1], 'medicine import must use the fixed EJ OpenAPI path');
$assertSame($importPayload, $capturedImportRequest[2], 'medicine import must send the complete canonical payload');
$assertTrue(
count(array_filter($capturedImportRequest[3], static fn (string $header): bool => str_starts_with($header, 'X-Signature: '))) === 1,
'medicine import must reuse the existing HMAC headers'
);
}
$bootstrapItem = EjMedicineBootstrapItem::fromRow([
'id' => 9,
'name' => "\u{200B} 黄芪 \u{200B}",
'unit' => ' 克 ',
'settlement_price' => '12.345650',
'retail_price' => '0.000050',
'status' => 1,
]);
$assertSame('9', $bootstrapItem['source_medicine_id'], 'bootstrap source medicine id must be a canonical string');
$assertSame('黄芪', $bootstrapItem['name'], 'bootstrap medicine name must be trimmed');
$assertSame('', $bootstrapItem['brand'], 'bootstrap brand must stay blank instead of copying supplier');
$assertSame('12.3457', $bootstrapItem['settlement_price'], 'six-decimal source price must round half-up to four decimals');
$assertSame('0.0001', $bootstrapItem['retail_price'], 'half-up rounding must preserve the 0.00005 boundary without floats');
$assertSame('1.2345', EjMedicineBootstrapItem::roundPrice('1.234549'), 'rounding below half must not increment');
$assertSame('1.2346', EjMedicineBootstrapItem::roundPrice('1.234550'), 'rounding at half must increment');
$sortedBootstrapItems = EjMedicineBootstrapItem::fromRows([
['id' => 10, 'name' => '十', 'unit' => '克', 'settlement_price' => '1.000000', 'retail_price' => '2.000000', 'status' => 1],
['id' => 2, 'name' => '二', 'unit' => '克', 'settlement_price' => '1.000000', 'retail_price' => '2.000000', 'status' => 1],
]);
$assertSame(['2', '10'], array_column($sortedBootstrapItems, 'source_medicine_id'), 'bootstrap items must use stable numeric id order');
try {
EjMedicineBootstrapItem::fromRows([
['id' => 2, 'name' => '二', 'unit' => '克', 'settlement_price' => '1.000000', 'retail_price' => '2.000000', 'status' => 1],
['id' => '02', 'name' => '重复', 'unit' => '克', 'settlement_price' => '1.000000', 'retail_price' => '2.000000', 'status' => 1],
]);
throw new RuntimeException('duplicate bootstrap source id unexpectedly accepted');
} catch (InvalidArgumentException $exception) {
$assertTrue(str_contains($exception->getMessage(), '重复'), 'bootstrap preflight must reject duplicate canonical source ids');
}
foreach ([
[false, 'RESET_TEST_CATALOG', 100],
[true, 'wrong', 100],
[true, 'RESET_TEST_CATALOG', 0],
[true, 'RESET_TEST_CATALOG', 501],
] as [$replace, $confirm, $batchSize]) {
try {
EjMedicineBootstrapService::assertCommandGate($replace, $confirm, $batchSize);
throw new RuntimeException('unsafe bootstrap command options unexpectedly accepted');
} catch (InvalidArgumentException $exception) {
$assertTrue($exception->getMessage() !== '', 'bootstrap command gate must return an actionable error');
}
}
EjMedicineBootstrapService::assertCommandGate(true, 'RESET_TEST_CATALOG', 500);
$batchItems = [
['source_medicine_id' => '1', 'name' => '一', 'brand' => '', 'unit' => '克', 'settlement_price' => '1.0000', 'retail_price' => '2.0000', 'status' => 1],
['source_medicine_id' => '2', 'name' => '二', 'brand' => '', 'unit' => '克', 'settlement_price' => '1.0000', 'retail_price' => '2.0000', 'status' => 1],
['source_medicine_id' => '3', 'name' => '三', 'brand' => '', 'unit' => '克', 'settlement_price' => '1.0000', 'retail_price' => '2.0000', 'status' => 1],
];
$bootstrapBatches = EjMedicineBootstrapService::buildBatches($batchItems, 2);
$assertSame(2, count($bootstrapBatches), 'bootstrap service must split imports by batch size');
$assertSame(
$bootstrapBatches,
EjMedicineBootstrapService::buildBatches(array_reverse($batchItems), 2),
'bootstrap batch payload and import ids must be deterministic regardless of input row order'
);
$assertTrue(
$bootstrapBatches[0]['import_id'] !== $bootstrapBatches[1]['import_id'],
'bootstrap import id must include batch ordinal and content identity'
);
$bootstrapSourceRows = [];
for ($bootstrapId = 1; $bootstrapId <= 654; ++$bootstrapId) {
$bootstrapSourceRows[] = [
'id' => $bootstrapId,
'name' => '药材' . $bootstrapId,
'unit' => '克',
'settlement_price' => '1.000000',
'retail_price' => '2.000000',
'status' => 1,
];
}
$bootstrapEvents = [];
$bootstrapImportIds = [];
$bootstrapExecution = EjMedicineBootstrapService::execute(
500,
static fn (): array => $bootstrapSourceRows,
static function (array $payload) use (&$bootstrapEvents, &$bootstrapImportIds): array {
$bootstrapEvents[] = 'import';
$bootstrapImportIds[] = $payload['import_id'];
$items = [];
foreach ($payload['items'] as $item) {
$sourceId = (int) $item['source_medicine_id'];
$items[] = [
'source_medicine_id' => $item['source_medicine_id'],
'medicine_code' => 'EJ' . str_pad((string) $sourceId, 6, '0', STR_PAD_LEFT),
'catalog_version' => $sourceId,
'action' => 'created',
];
}
return ['http_status' => 201, 'body' => ['code' => 0, 'data' => [
'import_id' => $payload['import_id'],
'item_count' => count($items),
'created_count' => count($items),
'existing_count' => 0,
'idempotent' => false,
'items' => $items,
]], 'request_id' => 'bootstrap-test'];
},
static function (array $rows) use (&$bootstrapEvents): array {
$bootstrapEvents[] = 'replace';
if (count($rows) !== 654) {
throw new RuntimeException('bootstrap projection did not receive all source rows');
}
return ['catalog' => 654, 'active_mappings' => 654, 'unmapped' => 0];
}
);
$assertSame(['import', 'import', 'replace'], $bootstrapEvents, 'projection replacement must start only after every remote batch succeeds');
$assertSame(2, $bootstrapExecution['batch_count'], '654 rows at batch size 500 must use exactly two deterministic imports');
$assertSame(654, $bootstrapExecution['catalog'], 'bootstrap execution must verify the exact replacement catalog count');
$secondRunImportIds = [];
EjMedicineBootstrapService::execute(
500,
static fn (): array => array_reverse($bootstrapSourceRows),
static function (array $payload) use (&$secondRunImportIds): array {
$secondRunImportIds[] = $payload['import_id'];
$items = array_map(static function (array $item): array {
$sourceId = (int) $item['source_medicine_id'];
return [
'source_medicine_id' => $item['source_medicine_id'],
'medicine_code' => 'EJ' . str_pad((string) $sourceId, 6, '0', STR_PAD_LEFT),
'catalog_version' => $sourceId,
'action' => 'existing',
];
}, $payload['items']);
return ['http_status' => 200, 'body' => ['code' => 0, 'data' => [
'import_id' => $payload['import_id'],
'item_count' => count($items),
'created_count' => 0,
'existing_count' => count($items),
'idempotent' => true,
'items' => $items,
]], 'request_id' => 'bootstrap-retry'];
},
static fn (): array => ['catalog' => 654, 'active_mappings' => 654, 'unmapped' => 0]
);
$assertSame($bootstrapImportIds, $secondRunImportIds, 'bootstrap retries must reuse identical import ids for identical batch content');
$validImportResponse = [
'http_status' => 201,
'body' => ['code' => 0, 'message' => 'ok', 'data' => [
'import_id' => $bootstrapBatches[0]['import_id'],
'item_count' => 2,
'created_count' => 2,
'existing_count' => 0,
'idempotent' => false,
'items' => [
['source_medicine_id' => '1', 'medicine_code' => 'EJ0001', 'catalog_version' => 1, 'action' => 'created'],
['source_medicine_id' => '2', 'medicine_code' => 'EJ0002', 'catalog_version' => 2, 'action' => 'created'],
],
]],
'request_id' => 'request-1',
];
$seenCodes = [];
$seenVersions = [];
$validatedItems = EjMedicineBootstrapService::validateImportResponse(
$validImportResponse,
$bootstrapBatches[0],
$seenCodes,
$seenVersions
);
$assertSame(['1', '2'], array_column($validatedItems, 'source_medicine_id'), 'bootstrap response must preserve exact source identities');
$invalidImportResponse = $validImportResponse;
$invalidImportResponse['body']['data']['items'][1]['medicine_code'] = 'EJ0001';
try {
$seenCodes = [];
$seenVersions = [];
EjMedicineBootstrapService::validateImportResponse(
$invalidImportResponse,
$bootstrapBatches[0],
$seenCodes,
$seenVersions
);
throw new RuntimeException('duplicate response medicine code unexpectedly accepted');
} catch (RuntimeException $exception) {
$assertTrue(str_contains($exception->getMessage(), 'medicine_code'), 'bootstrap response must reject duplicate medicine codes');
}
$projection = ['catalog' => ['OLD'], 'mapping' => ['OLD']];
$projectionOrder = [];
try {
EjMedicineBootstrapService::replaceProjectionWith(
[['local_medicine_id' => 1, 'medicine_code' => 'EJ0001']],
static function (callable $operation) use (&$projection) {
$before = $projection;
try {
return $operation();
} catch (Throwable $exception) {
$projection = $before;
throw $exception;
}
},
static function () use (&$projectionOrder): void { $projectionOrder[] = 'reference-lock'; },
static function () use (&$projectionOrder): array {
$projectionOrder[] = 'reference-count';
return ['submissions' => 0, 'callbacks' => 0, 'business_links' => 0];
},
static function () use (&$projectionOrder): void { $projectionOrder[] = 'projection-lock'; },
static function (array $rows) use (&$projection): void {
$projection = ['catalog' => $rows, 'mapping' => $rows];
throw new RuntimeException('simulated projection failure');
},
static fn (): array => ['catalog' => 1, 'active_mappings' => 1, 'unmapped' => 0]
);
throw new RuntimeException('projection replacement failure unexpectedly committed');
} catch (RuntimeException $exception) {
$assertSame('simulated projection failure', $exception->getMessage(), 'projection replacement must propagate write failures');
}
$assertSame(['catalog' => ['OLD'], 'mapping' => ['OLD']], $projection, 'projection replacement failure must roll back the old snapshot');
$assertSame(
['reference-lock', 'reference-count', 'projection-lock'],
$projectionOrder,
'bootstrap transaction must lock reference sources before checking zero references and locking projections'
);
$projectionWriteAttempted = false;
try {
EjMedicineBootstrapService::replaceProjectionWith(
[['local_medicine_id' => 1, 'medicine_code' => 'EJ0001']],
static fn (callable $operation) => $operation(),
static function (): void {},
static fn (): array => ['submissions' => 1, 'callbacks' => 0, 'business_links' => 0],
static function (): void {},
static function () use (&$projectionWriteAttempted): void { $projectionWriteAttempted = true; },
static fn (): array => ['catalog' => 1, 'active_mappings' => 1, 'unmapped' => 0]
);
throw new RuntimeException('nonzero pharmacy references unexpectedly allowed bootstrap replacement');
} catch (RuntimeException $exception) {
$assertTrue(str_contains($exception->getMessage(), '业务引用'), 'nonzero references must block bootstrap with an actionable error');
}
$assertSame(false, $projectionWriteAttempted, 'nonzero references must block before any projection write');
$failedBatchProjectionCalled = false;
$failedBatchCalls = 0;
try {
EjMedicineBootstrapService::execute(
500,
static fn (): array => $bootstrapSourceRows,
static function (array $payload) use (&$failedBatchCalls): array {
++$failedBatchCalls;
if ($failedBatchCalls === 2) {
return ['http_status' => 409, 'body' => ['code' => 409, 'message' => 'conflict', 'data' => null]];
}
$items = array_map(static function (array $item): array {
$sourceId = (int) $item['source_medicine_id'];
return [
'source_medicine_id' => $item['source_medicine_id'],
'medicine_code' => 'EJ' . str_pad((string) $sourceId, 6, '0', STR_PAD_LEFT),
'catalog_version' => $sourceId,
'action' => 'created',
];
}, $payload['items']);
return ['http_status' => 201, 'body' => ['code' => 0, 'data' => [
'import_id' => $payload['import_id'],
'item_count' => count($items),
'created_count' => count($items),
'existing_count' => 0,
'idempotent' => false,
'items' => $items,
]]];
},
static function () use (&$failedBatchProjectionCalled): array {
$failedBatchProjectionCalled = true;
return ['catalog' => 654, 'active_mappings' => 654, 'unmapped' => 0];
}
);
throw new RuntimeException('failed remote batch unexpectedly accepted');
} catch (RuntimeException $exception) {
$assertTrue(str_contains($exception->getMessage(), 'HTTP 409'), 'structured remote batch failure must remain actionable');
}
$assertSame(false, $failedBatchProjectionCalled, 'any remote batch failure must leave the old projection untouched');
$assertTrue(str_contains($migration, '`zyt_ej_pharmacy_sync_state`'), 'migration must persist sync cursor and outcome');
$assertTrue(
str_contains($migration, "`component` = 'doctor/medicine'"),
'medicine mapping menu migration must support installations whose medicine menu has no perms value'
);
foreach ([
'pharmacy.medicineMapping/lists',
'pharmacy.medicineMapping/status',
'pharmacy.medicineMapping/catalogOptions',
'pharmacy.medicineMapping/sync',
'pharmacy.medicineMapping/save',
'pharmacy.medicineMapping/unlink',
'tcm.prescriptionOrder/uploadToPharmacy',
'tcm.prescriptionOrder/submitGancaoRecipel',
] as $permission) {
$assertTrue(str_contains($migration, "'{$permission}'"), "migration must register permission {$permission}");
}
$assertTrue(
str_contains($migration, "'tcm.prescriptionOrder/confirmGancaoSubmission'"),
'migration must register a dedicated manual Gancao reconciliation permission'
);
$assertTrue(
str_contains($migration, 'controlled compatibility alias for the same pharmacy-upload capability'),
'migration must document the legacy URI as a controlled unified-upload alias'
);
$normalizedPermissions = (new AuthMiddleware())->formatUrl([
'pharmacy.medicineMapping/sync',
'pharmacy.medicineMapping/save',
'pharmacy.medicineMapping/unlink',
]);
$assertSame([
'pharmacy.medicinemapping/sync',
'pharmacy.medicinemapping/save',
'pharmacy.medicinemapping/unlink',
], $normalizedPermissions, 'AuthMiddleware must preserve independent pharmacy action permissions');
$mappingSql = substr(
$migration,
(int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_mapping`'),
(int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_ej_pharmacy_submission`')
- (int) strpos($migration, 'CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_mapping`')
);
$assertTrue(
str_contains($mappingSql, 'UNIQUE KEY `uk_medicine_code`'),
'bootstrap projection must map every remote medicine code to exactly one local medicine'
);
$tcmApi = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/api/tcm.ts');
$desktopOrderPage = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/order_list.vue');
$mobileOrderPage = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/order_list_h5.vue');
$gancaoReconcileComponent = (string) file_get_contents(
dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/components/GancaoSubmissionReconcileButton.vue'
);
$orderUtilsSource = (string) file_get_contents(
dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/components/prescription-order-utils.ts'
);
$mappingPage = (string) file_get_contents(
dirname(__DIR__, 3) . '/admin/src/views/pharmacy/medicine_mapping/index.vue'
);
$medicineNameSelect = (string) file_get_contents(
dirname(__DIR__, 3) . '/admin/src/components/medicine-name-select/index.vue'
);
$prescriptionEditorSources = [
(string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/components/tcm-prescription/index.vue'),
(string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/index.vue'),
(string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/consumer/prescription/list.vue'),
(string) file_get_contents(
dirname(__DIR__, 3) . '/admin/src/views/tcm/appointment/components/prescription-drawer.vue'
),
];
$prescriptionLibraryLogicSource = (string) file_get_contents(
dirname(__DIR__, 2) . '/app/adminapi/logic/tcm/PrescriptionLibraryLogic.php'
);
$assertTrue(
str_contains($tcmApi, "url: '/tcm.prescriptionOrder/uploadToPharmacy'"),
'unified pharmacy API client must use the independently registered upload URI'
);
$assertTrue(
str_contains($desktopOrderPage, "v-perms=\"['tcm.prescriptionOrder/uploadToPharmacy']\""),
'unified pharmacy buttons must require the new upload permission'
);
$assertTrue(
str_contains($mobileOrderPage, 'prescriptionOrderUploadToPharmacy'),
'mobile unified pharmacy upload must call the new API client'
);
$assertTrue(
str_contains($mobileOrderPage, "v-perms=\"['tcm.prescriptionOrder/uploadToPharmacy']\""),
'mobile unified pharmacy upload must require the new permission'
);
$assertTrue(
str_contains($prescriptionOrderListsSource, "if (\$mode === 'direct')")
&& str_contains($prescriptionOrderListsSource, "LOWER(TRIM(IFNULL(`ship_mode`,''))) = 'direct'"),
'supply filter must expose direct orders even before an EJ order number exists'
);
$assertTrue(
str_contains($prescriptionOrderLogicSource, "PharmacySupplyMode::label(PharmacySupplyMode::resolve(\$item))"),
'export must use the same three-state supply classification as filtering and UI'
);
$assertTrue(
str_contains($orderUtilsSource, 'export function supplyModeLabel(')
&& str_contains($desktopOrderPage, 'supplyModeLabel(row)')
&& str_contains($mobileOrderPage, 'supplyModeLabel(row)'),
'desktop and mobile order tags must share the three-state supply label helper'
);
$assertTrue(
substr_count($desktopOrderPage, "value: 'direct' as const") >= 1
&& substr_count($mobileOrderPage, "value: 'direct' as const") >= 1,
'desktop and mobile filters must both expose the direct supply state'
);
$assertTrue(
str_contains($desktopOrderPage, '<gancao-submission-reconcile-button')
&& str_contains($mobileOrderPage, '<gancao-submission-reconcile-button'),
'desktop and mobile detail actions must expose the same Gancao reconciliation flow'
);
$assertTrue(
str_contains($gancaoReconcileComponent, 'CONFIRM_SUCCESS')
&& str_contains($gancaoReconcileComponent, 'CONFIRM_NOT_CREATED')
&& str_contains($gancaoReconcileComponent, 'prescriptionOrderConfirmGancaoSubmission'),
'Gancao reconciliation UI must support both audited operator outcomes'
);
$assertTrue(
str_contains($gancaoReconcileComponent, "v-perms=\"['tcm.prescriptionOrder/confirmGancaoSubmission']\""),
'manual Gancao reconciliation UI must require its dedicated high-risk permission'
);
$assertTrue(
str_contains($gancaoReconcileComponent, "status === 'PENDING'")
&& str_contains($gancaoReconcileComponent, 'lease_expires_at'),
'expired Gancao PENDING claims must become actionable without an automatic resubmit'
);
$assertTrue(
!str_contains($mobileOrderPage, '<el-radio-group v-model="shipForm.ship_mode">'),
'mobile shipment confirmation must not present a ship-mode selector that the backend intentionally ignores'
);
$assertTrue(
str_contains($mobileOrderPage, '{{ shipDialogModeDisplay }}')
&& str_contains($mobileOrderPage, '请在订单详情顶部「发货类型」中设置,此处不可修改'),
'mobile shipment confirmation must show the persisted mode read-only and direct edits to order detail'
);
$assertTrue(
str_contains($orderUtilsSource, 'export function isRemoteSnapshotLocked('),
'desktop and mobile order controls must share one remote snapshot lock helper'
);
$assertTrue(
substr_count($desktopOrderPage, 'isRemoteSnapshotLocked(') >= 3
&& substr_count($mobileOrderPage, 'isRemoteSnapshotLocked(') >= 3,
'desktop and mobile edit, withdraw, audit-revoke and ship-mode controls must consistently honor remote locks'
);
$assertTrue(
str_contains($mappingPage, "row.mapping_status === 0 ? '-' : row.remote_brand || '-'"),
'unmapped medicine brand must render as a placeholder instead of an empty remote value'
);
$assertTrue(
str_contains($mappingPage, '<template v-if="row.mapping_status !== 0">'),
'unmapped medicine prices must render as placeholders instead of zero-valued remote prices'
);
$assertTrue(
str_contains($medicineNameSelect, ':value="item.id"')
&& str_contains($medicineNameSelect, "'update:medicineId'")
&& str_contains($medicineNameSelect, "emit('update:medicineId'"),
'medicine selector must return the selected local medicine id instead of collapsing duplicate names'
);
$assertTrue(
str_contains($medicineNameSelect, 'item.supplier')
&& str_contains($medicineNameSelect, 'item.unit'),
'medicine selector must distinguish duplicate names using local catalog metadata'
);
$assertTrue(
str_contains($medicineNameSelect, '() => [props.modelValue, props.medicineId]'),
'medicine selector must refresh when an asynchronously hydrated medicine id changes'
);
foreach ($prescriptionEditorSources as $source) {
$assertTrue(
str_contains($source, 'v-model:medicine-id=') && str_contains($source, 'medicine_id'),
'every prescription editor must preserve and bind the canonical medicine_id'
);
}
$assertTrue(
substr_count(implode("\n", $prescriptionEditorSources), 'exact.length === 1') >= 2,
'paste imports must only infer a medicine id from exactly one active exact-name match'
);
$assertTrue(
substr_count($prescriptionLibraryLogicSource, 'normalizeHerbIdentities') >= 3,
'prescription library add/edit must normalize medicine identities before persistence'
);
require __DIR__ . '/route_contracts.php';
echo "zyt pharmacy contract tests passed: {$passed}\n";