$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, ''), '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, '