1600 lines
76 KiB
PHP
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";
|