$child) { $value[$key] = $canonicalize($child); } return $value; }; $canonicalPayloadHash = static fn (array $payload): string => hash( 'sha256', json_encode($canonicalize($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ); $sourceRows = [ [ 'id' => 101, 'name' => '增量药材甲', 'unit' => '克', 'settlement_price' => '1.100000', 'retail_price' => '1.200000', 'status' => 1, ], [ 'id' => 202, 'name' => '增量药材乙', 'unit' => '克', 'settlement_price' => '2.100000', 'retail_price' => '2.200000', 'status' => 1, ], [ 'id' => 303, 'name' => '人工停用映射药材', 'unit' => '克', 'settlement_price' => '3.100000', 'retail_price' => '3.200000', 'status' => 1, ], ]; EjMedicineIncrementalPushService::assertCommandGate(false, '', 100); $blocked = false; try { EjMedicineIncrementalPushService::assertCommandGate(true, '', 100); } catch (InvalidArgumentException $exception) { $blocked = str_contains($exception->getMessage(), 'INCREMENTAL_NO_DELETE'); } $assertTrue($blocked, 'apply mode must require the non-destructive confirmation token'); foreach ([0, 501] as $invalidBatchSize) { try { EjMedicineIncrementalPushService::assertCommandGate(false, '', $invalidBatchSize); throw new RuntimeException('invalid incremental batch size unexpectedly accepted'); } catch (InvalidArgumentException $exception) { $assertTrue(str_contains($exception->getMessage(), 'batch-size'), 'invalid batch size must return an actionable error'); } } $plan = EjMedicineIncrementalPushService::plan( 100, static fn (): array => $sourceRows, static fn (array $localIds): array => [ ['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null], ['id' => 2, 'local_medicine_id' => 303, 'medicine_code' => 'EJ-303', 'status' => 0, 'delete_time' => 123], ] ); $assertSame(3, $plan['source_count'], 'dry-run must count every active local medicine'); $assertSame(2, $plan['candidate_count'], 'dry-run must replay active mappings and add never-mapped medicines'); $assertSame(1, $plan['batch_count'], 'dry-run must report the remote batch count'); $assertSame(1, $plan['mapped_count'], 'dry-run must count existing active mappings'); $assertSame(1, $plan['unmapped_count'], 'dry-run must identify local medicines without an active mapping'); $assertSame(1, $plan['preserved_inactive'], 'dry-run must preserve manually disabled or unlinked mappings'); $assertSame(0, $plan['remote_delete_count'], 'incremental push must never plan remote deletes'); $assertSame(0, $plan['local_delete_count'], 'incremental push must never plan local projection deletes'); $capturedPayloads = []; $capturedProjection = []; $heldLockToken = ''; $lockAcquireCount = 0; $lockRenewCount = 0; $lockReleaseCount = 0; $result = EjMedicineIncrementalPushService::execute( 1, 'zyt-inc-contract', static fn (): array => $sourceRows, static function (array $payload) use (&$capturedPayloads, $canonicalPayloadHash): array { $capturedPayloads[] = $payload; $sourceId = (string) $payload['items'][0]['source_medicine_id']; $action = $sourceId === '101' ? 'existing' : 'created'; return [ 'http_status' => $action === 'created' ? 201 : 200, 'body' => [ 'code' => 0, 'message' => 'ok', 'data' => [ 'source_system' => 'zyt', 'import_id' => $payload['import_id'], 'payload_hash' => $canonicalPayloadHash($payload), 'idempotent' => false, 'item_count' => 1, 'created_count' => $action === 'created' ? 1 : 0, 'existing_count' => $action === 'existing' ? 1 : 0, 'items' => [[ 'source_medicine_id' => $sourceId, 'medicine_code' => 'EJ-' . $sourceId, 'catalog_version' => $sourceId === '101' ? 10 : 11, 'action' => $action, ]], ], ], 'request_id' => 'request-' . $sourceId, ]; }, static function (array $rows) use (&$capturedProjection): array { $capturedProjection = $rows; return [ 'catalog_created' => 1, 'catalog_updated' => 1, 'mapping_created' => 1, 'mapping_updated' => 0, 'mapping_unchanged' => 1, ]; }, static fn (array $localIds): array => [ ['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null], ['id' => 2, 'local_medicine_id' => 303, 'medicine_code' => 'EJ-303', 'status' => 0, 'delete_time' => 123], ], static function (string $token) use (&$heldLockToken, &$lockAcquireCount): bool { ++$lockAcquireCount; if ($heldLockToken !== '') { return false; } $heldLockToken = $token; return true; }, static function (string $token) use (&$heldLockToken, &$lockRenewCount): bool { ++$lockRenewCount; return $heldLockToken !== '' && hash_equals($heldLockToken, $token); }, static function (string $token) use (&$heldLockToken, &$lockReleaseCount): void { ++$lockReleaseCount; if ($heldLockToken !== '' && hash_equals($heldLockToken, $token)) { $heldLockToken = ''; } } ); $assertSame(2, count($capturedPayloads), 'apply must send deterministic bounded batches'); $assertTrue( str_starts_with((string) $capturedPayloads[0]['import_id'], 'zyt-inc-contract-0001-'), 'incremental import id must include the caller-provided run id and batch ordinal' ); $assertSame(['101', '202'], array_column($capturedProjection, 'local_medicine_id'), 'projection upsert must retain source identity'); $assertSame(['EJ-101', 'EJ-202'], array_column($capturedProjection, 'medicine_code'), 'projection upsert must use EJ-returned codes'); $assertSame(1, $result['remote_created'], 'result must report remotely created medicines'); $assertSame(1, $result['remote_existing'], 'result must report idempotently existing medicines'); $assertSame(1, $result['preserved_inactive'], 'apply must not reactivate an operator-disabled mapping'); $assertSame(0, $result['remote_delete_count'], 'apply result must prove no EJ medicine was deleted'); $assertSame(0, $result['local_delete_count'], 'apply result must prove no ZYT projection was deleted'); $assertSame(1, $lockAcquireCount, 'apply must acquire one shared EJ synchronization lease'); $assertSame(5, $lockRenewCount, 'apply must renew its lease around every remote batch and before projection writes'); $assertSame(1, $lockReleaseCount, 'apply must release its synchronization lease'); $assertSame('', $heldLockToken, 'successful apply must not leave the synchronization lease held'); $candidateItems = array_merge($capturedPayloads[0]['items'], $capturedPayloads[1]['items']); $defaultBatches = EjMedicineIncrementalPushService::buildBatches($candidateItems, 1, ''); $retryBatches = EjMedicineIncrementalPushService::buildBatches(array_reverse($candidateItems), 1, ''); $assertSame( array_column($defaultBatches, 'import_id'), array_column($retryBatches, 'import_id'), 'default incremental import ids must be deterministic for identical content' ); $assertTrue( str_starts_with((string) $defaultBatches[0]['import_id'], 'zyt-incremental-0001-'), 'default run id must be stable so an interrupted apply can safely retry' ); $responseFor = static function ( array $payload, string $medicineCode, string $sourceSystem = 'zyt', ?string $responsePayloadHash = null ) use ($canonicalPayloadHash): array { $sourceId = (string) $payload['items'][0]['source_medicine_id']; return [ 'http_status' => 200, 'body' => ['code' => 0, 'message' => 'ok', 'data' => [ 'source_system' => $sourceSystem, 'import_id' => $payload['import_id'], 'payload_hash' => $responsePayloadHash ?? $canonicalPayloadHash($payload), 'idempotent' => true, 'item_count' => 1, 'created_count' => 0, 'existing_count' => 1, 'items' => [[ 'source_medicine_id' => $sourceId, 'medicine_code' => $medicineCode, 'catalog_version' => 99, 'action' => 'existing', ]], ]], 'request_id' => 'validation-' . $sourceId, ]; }; $alwaysAcquire = static fn (string $token): bool => $token !== ''; $alwaysRenew = static fn (string $token): bool => $token !== ''; $releaseNoop = static function (string $token): void {}; foreach ([ 'source_system' => static fn (array $payload): array => $responseFor($payload, 'EJ-202', 'other'), 'payload_hash' => static fn (array $payload): array => $responseFor($payload, 'EJ-202', 'zyt', str_repeat('0', 64)), ] as $field => $invalidImporter) { $projectionCalled = false; try { EjMedicineIncrementalPushService::execute( 1, 'response-validation', static fn (): array => [$sourceRows[1]], $invalidImporter, static function () use (&$projectionCalled): array { $projectionCalled = true; return []; }, static fn (array $localIds): array => [], $alwaysAcquire, $alwaysRenew, $releaseNoop ); throw new RuntimeException("invalid {$field} unexpectedly accepted"); } catch (RuntimeException $exception) { $assertTrue(str_contains($exception->getMessage(), $field), "incremental response must reject invalid {$field}"); } $assertSame(false, $projectionCalled, "invalid {$field} must block local projection writes"); } $projectionCalled = false; try { EjMedicineIncrementalPushService::execute( 1, 'mapping-mismatch', static fn (): array => [$sourceRows[0]], static fn (array $payload): array => $responseFor($payload, 'EJ-DIFFERENT'), static function () use (&$projectionCalled): array { $projectionCalled = true; return []; }, static fn (array $localIds): array => [ ['id' => 1, 'local_medicine_id' => 101, 'medicine_code' => 'EJ-101', 'status' => 1, 'delete_time' => null], ], $alwaysAcquire, $alwaysRenew, $releaseNoop ); throw new RuntimeException('active mapping code mismatch unexpectedly accepted'); } catch (DomainException $exception) { $assertTrue(str_contains($exception->getMessage(), '不一致'), 'active mapping replay must reject a different EJ code'); } $assertSame(false, $projectionCalled, 'active mapping mismatch must not overwrite its local projection'); $sourceLoaded = false; $failedAcquireReleased = false; try { EjMedicineIncrementalPushService::execute( 1, 'concurrent-run', static function () use (&$sourceLoaded): array { $sourceLoaded = true; return []; }, null, null, null, static fn (string $token): bool => false, $alwaysRenew, static function (string $token) use (&$failedAcquireReleased): void { $failedAcquireReleased = true; } ); throw new RuntimeException('concurrent incremental run unexpectedly acquired the lease'); } catch (DomainException $exception) { $assertTrue(str_contains($exception->getMessage(), '正在执行'), 'concurrent apply must fail with a business error'); } $assertSame(false, $sourceLoaded, 'concurrent apply must stop before reading or pushing source medicines'); $assertSame(false, $failedAcquireReleased, 'a failed lock acquisition must not release another run lease'); $leaseRenewals = 0; $leaseFailureReleased = false; $projectionCalled = false; try { EjMedicineIncrementalPushService::execute( 1, 'lease-loss', static fn (): array => [$sourceRows[1]], static fn (array $payload): array => $responseFor($payload, 'EJ-202'), static function () use (&$projectionCalled): array { $projectionCalled = true; return []; }, static fn (array $localIds): array => [], $alwaysAcquire, static function () use (&$leaseRenewals): bool { ++$leaseRenewals; return $leaseRenewals === 1; }, static function (string $token) use (&$leaseFailureReleased): void { $leaseFailureReleased = true; } ); throw new RuntimeException('expired synchronization lease unexpectedly allowed projection'); } catch (DomainException $exception) { $assertTrue(str_contains($exception->getMessage(), '锁已失效'), 'lease loss must return an actionable retry error'); } $assertSame(false, $projectionCalled, 'lease loss after a remote response must block local projection writes'); $assertSame(true, $leaseFailureReleased, 'lease loss must still release the caller-owned lock token'); $serviceSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineIncrementalPushService.php'); $assertTrue(!str_contains($serviceSource, '->delete('), 'incremental service must not call model delete'); $assertTrue(!str_contains($serviceSource, '->delete()'), 'incremental service must not call model delete without arguments'); echo "incremental EJ medicine push tests passed: {$passed}\n";